diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index bf37343c..b5e2195d 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -7,7 +7,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - node: ["18.x", "20.x", "22.x"] + node: ['18.x', '20.x', '22.x', '24.x'] os: [ubuntu-latest, macOS-latest] steps: diff --git a/.gitignore b/.gitignore index 8ab705bc..7f262210 100644 --- a/.gitignore +++ b/.gitignore @@ -110,5 +110,6 @@ package/ dist/*.ts dist/**/*.ts +dist/**/*.mts !dist/*.map !dist/**/*.map diff --git a/README.md b/README.md index beaba63a..a5783659 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,14 @@
logo -

Fast, lightweight (~4 KB gzipped) and reusable data fetching

+

Fast, lightweight (~5 KB gzipped) and reusable data fetching

-The last fetch wrapper you will ever need. "fetchff" stands for "fetch fast & flexibly" [npm-url]: https://npmjs.org/package/fetchff [npm-image]: https://img.shields.io/npm/v/fetchff.svg -[![NPM version][npm-image]][npm-url] [![Blazing Fast](https://badgen.now.sh/badge/speed/blazing%20%F0%9F%94%A5/green)](https://github.com/MattCCC/fetchff) [![Code Coverage](https://img.shields.io/badge/coverage-96.81-green)](https://github.com/MattCCC/fetchff) [![npm downloads](https://img.shields.io/npm/dm/fetchff.svg?color=lightblue)](http://npm-stat.com/charts.html?package=fetchff) [![gzip size](https://img.shields.io/bundlephobia/minzip/fetchff)](https://bundlephobia.com/result?p=fetchff) [![snyk](https://snyk.io/test/github/MattCCC/fetchff/badge.svg)](https://security.snyk.io/package/npm/fetchff) +[![NPM version][npm-image]][npm-url] [![Blazing Fast](https://badgen.now.sh/badge/speed/blazing%20%F0%9F%94%A5/green)](https://github.com/MattCCC/fetchff) [![Code Coverage](https://img.shields.io/badge/coverage-96.93-green)](https://github.com/MattCCC/fetchff) [![npm downloads](https://img.shields.io/npm/dm/fetchff.svg?color=lightblue)](http://npm-stat.com/charts.html?package=fetchff) [![gzip size](https://img.shields.io/bundlephobia/minzip/fetchff)](https://bundlephobia.com/result?p=fetchff) [![snyk](https://snyk.io/test/github/MattCCC/fetchff/badge.svg)](https://security.snyk.io/package/npm/fetchff)
@@ -36,18 +35,15 @@ Also, managing multitude of API connections in large applications can be complex To address these challenges, the `fetchf()` provides several enhancements: 1. **Consistent Error Handling:** - - In JavaScript, the native `fetch()` function does not reject the Promise for HTTP error statuses such as 404 (Not Found) or 500 (Internal Server Error). Instead, `fetch()` resolves the Promise with a `Response` object, where the `ok` property indicates the success of the request. If the request encounters a network error or fails due to other issues (e.g., server downtime), `fetch()` will reject the Promise. - The `fetchff` plugin aligns error handling with common practices and makes it easier to manage errors consistently by rejecting erroneous status codes. 2. **Enhanced Retry Mechanism:** - - **Retry Configuration:** You can configure the number of retries, delay between retries, and exponential backoff for failed requests. This helps to handle transient errors effectively. - **Custom Retry Logic:** The `shouldRetry` asynchronous function allows for custom retry logic based on the error from `response.error` and attempt count, providing flexibility to handle different types of failures. - **Retry Conditions:** Errors are only retried based on configurable retry conditions, such as specific HTTP status codes or error types. 3. **Improved Error Visibility:** - - **Error Wrapping:** The `createApiFetcher()` and `fetchf()` wrap errors in a custom `ResponseError` class, which provides detailed information about the request and response. This makes debugging easier and improves visibility into what went wrong. 4. **Extended settings:** @@ -87,10 +83,11 @@ To address these challenges, the `fetchf()` provides several enhancements: - **Smart Retry Mechanism**: Features exponential backoff for intelligent error handling and retry mechanisms. - **Request Deduplication**: Set the time during which requests are deduplicated (treated as same request). - **Cache Management**: Dynamically manage cache with configurable expiration, custom keys, and selective invalidation. +- **Network Revalidation**: Automatically revalidate data on window focus and network reconnection for fresh data. - **Dynamic URLs Support**: Easily manage routes with dynamic parameters, such as `/user/:userId`. - **Error Handling**: Flexible error management at both global and individual request levels. - **Request Cancellation**: Utilizes `AbortController` to cancel previous requests automatically. -- **Timeouts**: Set timeouts globally or per request to prevent hanging operations. +- **Adaptive Timeouts**: Smart timeout adjustment based on connection speed for optimal user experience. - **Fetching Strategies**: Handle failed requests with various strategies - promise rejection, silent hang, soft fail, or default response. - **Requests Chaining**: Easily chain multiple requests using promises for complex API interactions. - **Native `fetch()` Support**: Utilizes the built-in `fetch()` API, providing a modern and native solution for making HTTP requests. @@ -122,9 +119,11 @@ yarn add fetchff ### Standalone usage -#### `fetchf()` +#### `fetchf(url, config)` + +_Alias: `fetchff(url, config)`_ -It is a functional wrapper for `fetch()`. It seamlessly enhances it with additional settings like the retry mechanism and error handling improvements. The `fetchf()` can be used directly as a function, simplifying the usage and making it easier to integrate with functional programming styles. The `fetchf()` makes requests independently of `createApiFetcher()` settings. +A simple function that wraps the native `fetch()` and adds extra features like retries and better error handling. Use `fetchf()` directly for quick, enhanced requests - no need to set up `createApiFetcher()`. It works independently and is easy to use in any codebase. #### Example @@ -139,9 +138,58 @@ const { data, error } = await fetchf('/api/user-details', { }); ``` -### Multiple API Endpoints +### Global Configuration + +#### `getDefaultConfig()` + +
+ Click to expand +
+ +Returns the current global default configuration used for all requests. This is useful for inspecting or debugging the effective global settings. + +```typescript +import { getDefaultConfig } from 'fetchff'; + +// Retrieve the current global default config +const config = getDefaultConfig(); +console.log('Current global fetchff config:', config); +``` + +
+ +#### `setDefaultConfig(customConfig)` + +
+ Click to expand +
+ +Allows you to globally override the default configuration for all requests. This is useful for setting application-wide defaults like timeouts, headers, or retry policies. + +```typescript +import { setDefaultConfig } from 'fetchff'; + +// Set global defaults for all requests +setDefaultConfig({ + timeout: 10000, // 10 seconds for all requests + headers: { + Authorization: 'Bearer your-token', + }, + retry: { + retries: 2, + delay: 1500, + }, +}); + +// All subsequent requests will use these defaults +const { data } = await fetchf('/api/data'); // Uses 10s timeout and retry config +``` + +
+ +### Instance with many API endpoints -#### `createApiFetcher()` +#### `createApiFetcher(config)`
Click to expand @@ -184,7 +232,7 @@ const { data, error } = await api.getUser({ All the Request Settings can be directly used in the function as global settings for all endpoints. They can be also used within the `endpoints` property (on per-endpoint basis). The exposed `endpoints` property is as follows: - **`endpoints`**: - Type: `EndpointsConfig` + Type: `EndpointsConfig` List of your endpoints. Each endpoint is an object that accepts all the Request Settings (see the Basic Settings below). The endpoints are required to be specified. #### How It Works @@ -245,9 +293,238 @@ You can access `api.config` property directly to modify global headers and other You can access `api.endpoints` property directly to modify the endpoints list. This can be useful if you want to append or remove global endpoints. This is a property, not a function. -#### `api.getInstance()` +
+ +### Advanced Utilities + +
+ Click to expand +
+ +#### Cache Management + +##### `mutate(key, newData, settings)` + +Programmatically update cached data without making a network request. Useful for optimistic updates or reflecting changes from other operations. + +**Parameters:** + +- `key` (string): The cache key to update +- `newData` (any): The new data to store in cache +- `settings` (object, optional): Configuration options + - `revalidate` (boolean): Whether to trigger background revalidation after update + +```typescript +import { mutate } from 'fetchff'; + +// Update cache for a specific cache key +await mutate('/api/users', newUserData); + +// Update with options +await mutate('/api/users', updatedData, { + revalidate: true, // Trigger background revalidation +}); +``` + +##### `getCache(key)` + +Directly retrieve cached data for a specific cache key. Useful for reading the current cached response without triggering a network request. + +**Parameters:** + +- `key` (string): The cache key to retrieve (equivalent to `cacheKey` from request config or `config.cacheKey` from response object) + +**Returns:** The cached response object, or `null` if not found + +```typescript +import { getCache } from 'fetchff'; + +// Get cached data for a specific key assuming you set {cacheKey: ''/api/user-profile'} in config +const cachedResponse = getCache('/api/user-profile'); +if (cachedResponse) { + console.log('Cached user profile:', cachedResponse.data); +} +``` + +##### `setCache(key, response, ttl, staleTime)` + +Directly set cache data for a specific key. Unlike `mutate()`, this doesn't trigger revalidation by default. This is a low level function to directly set cache data based on particular key. If unsure, use the `mutate()` with `revalidate: false` instead. + +**Parameters:** + +- `key` (string): The cache key to set. It must match the cache key of the request. +- `response` (any): The full response object to store in cache +- `ttl` (number, optional): Time to live for the cache entry, in seconds. Determines how long the cached data remains valid before expiring. If not specified, the default `0` value will be used (discard cache immediately), if `-1` specified then the cache will be held until manually removed using `deleteCache(key)` function. +- `staleTime` (number, optional): Duration, in seconds, for which cached data is considered "fresh" before it becomes eligible for background revalidation. If not specified, the default stale time applies. + +```typescript +import { setCache } from 'fetchff'; + +// Set cache data with custom ttl and staleTime +setCache('/api/user-profile', userData, 600, 60); // Cache for 10 minutes, fresh for 1 minute + +// Set cache for specific endpoint infinitely +setCache('/api/user-settings', userSettings, -1); +``` + +##### `deleteCache(key)` + +Remove cached data for a specific cache key. Useful for cache invalidation when you know data is stale. + +**Parameters:** + +- `key` (string): The cache key to delete + +```typescript +import { deleteCache } from 'fetchff'; + +// Delete specific cache entry +deleteCache('/api/user-profile'); + +// Delete cache after user logout +const logout = () => { + deleteCache('/api/user/*'); // Delete all user-related cache +}; +``` + +#### Revalidation Management + +##### `revalidate(key, isStaleRevalidation)` + +Manually trigger revalidation for a specific cache entry, forcing a fresh network request to update the cached data. + +**Parameters:** + +- `key` (string): The cache key to revalidate +- `isStaleRevalidation` (boolean, optional): Whether this is a background revalidation that doesn't mark as in-flight + +```typescript +import { revalidate } from 'fetchff'; + +// Revalidate specific cache entry +await revalidate('/api/user-profile'); + +// Revalidate with custom cache key +await revalidate('custom-cache-key'); + +// Background revalidation (doesn't mark as in-flight) +await revalidate('/api/user-profile', true); +``` + +##### `revalidateAll(type, isStaleRevalidation)` + +Trigger revalidation for all cache entries associated with a specific event type (focus or online). + +**Parameters:** + +- `type` (string): The revalidation event type ('focus' or 'online') +- `isStaleRevalidation` (boolean, optional): Whether this is a background revalidation + +```typescript +import { revalidateAll } from 'fetchff'; + +// Manually trigger focus revalidation for all relevant entries +revalidateAll('focus'); + +// Manually trigger online revalidation for all relevant entries +revalidateAll('online'); +``` + +##### `removeRevalidators(type)` + +Clean up revalidation event listeners for a specific event type. Useful for preventing memory leaks when you no longer need automatic revalidation. + +**Parameters:** + +- `type` (string): The revalidation event type to remove ('focus' or 'online') + +```typescript +import { removeRevalidators } from 'fetchff'; + +// Remove all focus revalidation listeners +removeRevalidators('focus'); + +// Remove all online revalidation listeners +removeRevalidators('online'); + +// Typically called during cleanup +// e.g., in React useEffect cleanup or when unmounting components +``` + +#### Pub/Sub System + +##### `subscribe(key, callback)` + +Subscribe to cache updates and data changes. Receive notifications when specific cache entries are updated. + +**Parameters:** + +- `key` (string): The cache key to subscribe to +- `callback` (function): Function called when cache is updated + - `response` (any): The full response object + +**Returns:** Function to unsubscribe from updates + +```typescript +import { subscribe } from 'fetchff'; + +// Subscribe to cache changes for a specific key +const unsubscribe = subscribe('/api/user-data', (response) => { + console.log('Cache updated with response:', response); + console.log('Response data:', response.data); + console.log('Response status:', response.status); +}); + +// Clean up subscription when no longer needed +unsubscribe(); +``` + +#### Request Management + +##### `abortRequest(key, error)` + +Programmatically abort in-flight requests for a specific cache key or URL pattern. + +**Parameters:** -If you initialize API handler with your custom `fetcher`, then this function will return the instance created using `fetcher.create()` function. Your fetcher can include anything. It will be triggering `fetcher.request()` instead of native fetch() that is available by default. It gives you ultimate flexibility on how you want your requests to be made. +- `key` (string): The cache key or URL pattern to abort +- `error` (Error, optional): Custom error to throw for aborted requests + +```typescript +import { abortRequest } from 'fetchff'; + +// Abort specific request by cache key +abortRequest('/api/slow-operation'); + +// Useful for cleanup when component unmounts or route changes +const cleanup = () => { + abortRequest('/api/user-dashboard'); +}; +``` + +#### Network Detection + +##### `isSlowConnection()` + +Check if the user is on a slow network connection (2G/3G). Useful for adapting application behavior based on connection speed. + +**Parameters:** None + +**Returns:** Boolean indicating if connection is slow + +```typescript +import { isSlowConnection } from 'fetchff'; + +// Check connection speed and adapt behavior +if (isSlowConnection()) { + console.log('User is on a slow connection'); + // Reduce image quality, disable auto-refresh, etc. +} + +// Use in conditional logic +const shouldAutoRefresh = !isSlowConnection(); +const imageQuality = isSlowConnection() ? 'low' : 'high'; +```
@@ -275,20 +552,198 @@ You can pass the settings: You can also use all native [`fetch()` settings](https://developer.mozilla.org/en-US/docs/Web/API/fetch#parameters). -| | Type | Default | Description | -| -------------------------- | ------------------------------------------------------------------------------------------------------ | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| baseURL
(alias: apiUrl) | `string` | | Your API base url. | -| url | `string` | | URL path e.g. /user-details/get | -| method | `string` | `GET` | Default request method e.g. GET, POST, DELETE, PUT etc. All methods are supported. | -| params | `object`
`URLSearchParams`
`NameValuePair[]` | `{}` | Query Parameters - a key-value pairs added to the URL to send extra information with a request. If you pass an object, it will be automatically converted. It works with nested objects, arrays and custom data structures similarly to what `jQuery` used to do in the past. If you use `createApiFetcher()` then it is the first argument of your `api.yourEndpoint()` function. You can still pass configuration in 3rd argument if want to.

You can pass key-value pairs where the values can be strings, numbers, or arrays. For example, if you pass `{ foo: [1, 2] }`, it will be automatically serialized into `foo[]=1&foo[]=2` in the URL. | -| body
(alias: data) | `object`
`string`
`FormData`
`URLSearchParams`
`Blob`
`ArrayBuffer`
`ReadableStream` | `{}` | The body is the data sent with the request, such as JSON, text, or form data, included in the request payload for POST, PUT, or PATCH requests. | -| urlPathParams | `object` | `{}` | It lets you dynamically replace segments of your URL with specific values in a clear and declarative manner. This feature is especially handy for constructing URLs with variable components or identifiers.

For example, suppose you need to update user details and have a URL template like `/user-details/update/:userId`. With `urlPathParams`, you can replace `:userId` with a real user ID, such as `123`, resulting in the URL `/user-details/update/123`. | -| flattenResponse | `boolean` | `false` | When set to `true`, this option flattens the nested response data. This means you can access the data directly without having to use `response.data.data`. It works only if the response structure includes a single `data` property. | -| defaultResponse | `any` | `null` | Default response when there is no data or when endpoint fails depending on the chosen `strategy` | -| withCredentials | `boolean` | `false` | Indicates whether credentials (such as cookies) should be included with the request. This equals to `credentials: "include"` in native `fetch()`. In Node.js, cookies are not managed automatically. Use a fetch polyfill or library that supports cookies if needed. | -| timeout | `number` | `30000` | You can set a request timeout in milliseconds. By default 30 seconds (30000 ms). The timeout option applies to each individual request attempt including retries and polling. `0` means that the timeout is disabled. | -| logger | `Logger` | `null` | You can additionally specify logger object with your custom logger to automatically log the errors to the console. It should contain at least `error` and `warn` functions. | -| fetcher | `FetcherInstance` | | A custom adapter (an instance / object) that exposes `create()` function so to create instance of API Fetcher. The `create()` should return `request()` function that would be used when making the requests. The native `fetch()` is used if the fetcher is not provided. | +| | Type | Default | Description | +| -------------------------- | ------------------------------------------------------------------------------------------------------ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| baseURL
(alias: apiUrl) | `string` | `undefined` | Your API base url. | +| url | `string` | `undefined` | URL path e.g. /user-details/get | +| method | `string` | `'GET'` | Default request method e.g. GET, POST, DELETE, PUT etc. All methods are supported. | +| params | `object`
`URLSearchParams`
`NameValuePair[]` | `undefined` | Query Parameters - a key-value pairs added to the URL to send extra information with a request. If you pass an object, it will be automatically converted. It works with nested objects, arrays and custom data structures similarly to what `jQuery` used to do in the past. If you use `createApiFetcher()` then it is the first argument of your `api.yourEndpoint()` function. You can still pass configuration in 3rd argument if want to.

You can pass key-value pairs where the values can be strings, numbers, or arrays. For example, if you pass `{ foo: [1, 2] }`, it will be automatically serialized into `foo[]=1&foo[]=2` in the URL. | +| body
(alias: data) | `object`
`string`
`FormData`
`URLSearchParams`
`Blob`
`ArrayBuffer`
`ReadableStream` | `undefined` | The body is the data sent with the request, such as JSON, text, or form data, included in the request payload for POST, PUT, or PATCH requests. | +| urlPathParams | `object` | `undefined` | It lets you dynamically replace segments of your URL with specific values in a clear and declarative manner. This feature is especially handy for constructing URLs with variable components or identifiers.

For example, suppose you need to update user details and have a URL template like `/user-details/update/:userId`. With `urlPathParams`, you can replace `:userId` with a real user ID, such as `123`, resulting in the URL `/user-details/update/123`. | +| flattenResponse | `boolean` | `false` | When set to `true`, this option flattens the nested response data. This means you can access the data directly without having to use `response.data.data`. It works only if the response structure includes a single `data` property. | +| select | `(data: any) => any` | `undefined` | Function to transform or select a subset of the response data before it is returned. Called with the raw response data and should return the transformed data. Useful for mapping, picking, or shaping the response. | +| defaultResponse | `any` | `null` | Default response when there is no data or when endpoint fails depending on the chosen `strategy` | +| withCredentials | `boolean` | `false` | Indicates whether credentials (such as cookies) should be included with the request. This equals to `credentials: "include"` in native `fetch()`. In Node.js, cookies are not managed automatically. Use a fetch polyfill or library that supports cookies if needed. | +| timeout | `number` | `30000` / `60000` | You can set a request timeout in milliseconds. **Default is adaptive**: 30 seconds (30000 ms) for normal connections, 60 seconds (60000 ms) on slow connections (2G/3G). The timeout option applies to each individual request attempt including retries and polling. `0` means that the timeout is disabled. | +| dedupeTime | `number` | `0` | Time window, in milliseconds, during which identical requests are deduplicated (treated as single request). If set to `0`, deduplication is disabled. | +| cacheTime | `number` | `undefined` | Specifies the duration, in seconds, for which a cache entry is considered "fresh." Once this time has passed, the entry is considered stale and may be refreshed with a new request. Set to -1 for indefinite cache. By default no caching. | +| staleTime | `number` | `undefined` | Specifies the duration, in seconds, for which cached data is considered "fresh." During this period, cached data will be returned immediately, but a background revalidation (network request) will be triggered to update the cache. If set to `0`, background revalidation is disabled and revalidation is triggered on every access. | +| refetchOnFocus | `boolean` | `false` | When set to `true`, automatically revalidates (refetches) data when the browser window regains focus. **Note: This bypasses the cache and always makes a fresh network request** to ensure users see the most up-to-date data when they return to your application from another tab or window. Particularly useful for applications that display real-time or frequently changing data, but should be used judiciously to avoid unnecessary network traffic. | +| refetchOnReconnect | `boolean` | `false` | When set to `true`, automatically revalidates (refetches) data when the browser regains internet connectivity after being offline. **This uses background revalidation to silently update data** without showing loading states to users. Helps ensure your application displays fresh data after network interruptions. Works by listening to the browser's `online` event. | +| logger | `Logger` | `null` | You can additionally specify logger object with your custom logger to automatically log the errors to the console. It should contain at least `error` and `warn` functions. | +| fetcher | `CustomFetcher` | `undefined` | A custom fetcher async function. By default, the native `fetch()` is used. If you use your own fetcher, default response parsing e.g. `await response.json()` call will be skipped. Your fetcher should return data. | + +> 📋 **Additional Settings Available:** +> The table above shows the most commonly used settings. Many more advanced configuration options are available and documented in their respective sections below, including: +> +> - **🔄 Retry Mechanism** - `retries`, `delay`, `maxDelay`, `backoff`, `resetTimeout`, `retryOn`, `shouldRetry` +> - **đŸ“ļ Polling Configuration** - `pollingInterval`, `pollingDelay`, `maxPollingAttempts`, `shouldStopPolling` +> - **đŸ—„ī¸ Cache Management** - `cacheKey`, `cacheBuster`, `skipCache`, `cacheErrors` +> - **✋ Request Cancellation** - `cancellable`, `rejectCancelled` +> - **🌀 Interceptors** - `onRequest`, `onResponse`, `onError`, `onRetry` +> - **🔍 Error Handling** - `strategy` + +### Performance Implications of Settings + +Understanding the performance impact of different settings helps you optimize for your specific use case: + +#### **High-Performance Settings** + +**Minimize Network Requests:** + +```typescript +// Aggressive caching for static data +const staticConfig = { + cacheTime: 3600, // 1 hour cache + staleTime: 1800, // 30 minutes freshness + dedupeTime: 10000, // 10 seconds deduplication +}; + +// Result: 90%+ reduction in network requests +``` + +**Optimize for Mobile/Slow Connections:** + +```typescript +const mobileOptimized = { + timeout: 60000, // Longer timeout for slow connections (auto-adaptive) + retry: { + retries: 5, // More retries for unreliable connections + delay: 2000, // Longer initial delay (auto-adaptive) + backoff: 2.0, // Aggressive backoff + }, + cacheTime: 900, // Longer cache on mobile +}; +``` + +#### **Memory vs Network Trade-offs** + +**Memory-Efficient (Low Cache):** + +```typescript +const memoryEfficient = { + cacheTime: 60, // Short cache (1 minute) + staleTime: undefined, // No stale-while-revalidate + dedupeTime: 1000, // Short deduplication +}; +// Pros: Low memory usage +// Cons: More network requests, slower perceived performance +``` + +**Network-Efficient (High Cache):** + +```typescript +const networkEfficient = { + cacheTime: 1800, // Long cache (30 minutes) + staleTime: 300, // 5 minutes stale-while-revalidate + dedupeTime: 5000, // Longer deduplication +}; +// Pros: Fewer network requests, faster user experience +// Cons: Higher memory usage, potentially stale data +``` + +#### **Feature Performance Impact** + +| Feature | Performance Impact | Best Use Case | +| -------------------------- | ----------------------------------- | ------------------------------------------- | +| **Caching** | âŦ‡ī¸ 70-90% fewer requests | Static or semi-static data | +| **Deduplication** | âŦ‡ī¸ 50-80% fewer concurrent requests | High-traffic applications | +| **Stale-while-revalidate** | âŦ†ī¸ 90% faster perceived loading | Dynamic data that tolerates brief staleness | +| **Request cancellation** | âŦ‡ī¸ Reduced bandwidth waste | Search-as-you-type, rapid navigation | +| **Retry mechanism** | âŦ†ī¸ 95%+ success rate | Mission-critical operations | +| **Polling** | âŦ†ī¸ Real-time updates | Live data monitoring | + +#### **Adaptive Performance by Connection** + +FetchFF automatically adapts timeouts and retry delays based on connection speed: + +```typescript +// Automatic adaptation (no configuration needed) +const adaptiveRequest = fetchf('/api/data'); + +// On fast connections (WiFi/4G): +// - timeout: 30 seconds +// - retry delay: 1 second → 1.5s → 2.25s... +// - max retry delay: 30 seconds + +// On slow connections (2G/3G): +// - timeout: 60 seconds +// - retry delay: 2 seconds → 3s → 4.5s... +// - max retry delay: 60 seconds +``` + +#### **Performance Patterns** + +**Progressive Loading (Best UX):** + +```typescript +// Layer 1: Instant response with cache +const quickData = await fetchf('/api/summary', { + cacheTime: 300, + staleTime: 60, +}); + +// Layer 2: Background enhancement +fetchf('/api/detailed-data', { + strategy: 'silent', + cacheTime: 600, + onResponse(response) { + updateUIWithDetailedData(response.data); + }, +}); +``` + +**Bandwidth-Conscious Loading:** + +```typescript +// Check connection before expensive operations +import { isSlowConnection } from 'fetchff'; + +const loadUserDashboard = async () => { + const isSlowConn = isSlowConnection(); + + // Essential data always loads + const userData = await fetchf('/api/user', { + cacheTime: isSlowConn ? 600 : 300, // Longer cache on slow connections + }); + + // Optional data only on fast connections + if (!isSlowConn) { + fetchf('/api/user/analytics', { strategy: 'silent' }); + fetchf('/api/user/recommendations', { strategy: 'silent' }); + } +}; +``` + +#### **Performance Monitoring** + +Track key metrics to optimize your settings: + +```typescript +const performanceConfig = { + onRequest(config) { + console.time(`request-${config.url}`); + }, + + onResponse(response) { + console.timeEnd(`request-${response.config.url}`); + + // Track cache hit rate + if (response.fromCache) { + incrementMetric('cache.hits'); + } else { + incrementMetric('cache.misses'); + } + }, + + onError(error) { + incrementMetric('requests.failed'); + console.warn('Request failed:', error.config.url, error.status); + }, +}; +``` + +> â„šī¸ **Note:** This is just an example. You need to implement the `incrementMetric` function yourself to record or report performance metrics as needed in your application. @@ -343,9 +798,6 @@ const { data } = await fetchf('https://api.example.com/endpoint', { The `fetchff` plugin automatically injects a set of default headers into every request. These default headers help ensure that requests are consistent and include necessary information for the server to process them correctly. -- **`Content-Type`**: `application/json;charset=utf-8` - Specifies that the request body contains JSON data and sets the character encoding to UTF-8. - - **`Accept`**: `application/json, text/plain, */*` Indicates the media types that the client is willing to receive from the server. This includes JSON, plain text, and any other types. @@ -355,6 +807,22 @@ The `fetchff` plugin automatically injects a set of default headers into every r > âš ī¸ **Accept-Encoding in Node.js:** > In Node.js, decompression is handled by the fetch implementation, and users should ensure their environment supports the encodings. +- **`Content-Type`**: + Set automatically based on the request body type: + - For JSON-serializable bodies (objects, arrays, etc.): + `application/json; charset=utf-8` + - For `URLSearchParams`: + `application/x-www-form-urlencoded` + - For `ArrayBuffer`/typed arrays: + `application/octet-stream` + - For `FormData`, `Blob`, `File`, or `ReadableStream`: + **Not set** as the header is handled automatically by the browser and by Node.js 18+ native fetch. + + The `Content-Type` header is **never overridden** if you set it manually. + +**Summary:** +You only need to set headers manually if you want to override these defaults. Otherwise, `fetchff` will handle the correct headers for most use cases, including advanced scenarios like file uploads, form submissions, and binary data. + ## 🌀 Interceptors @@ -381,63 +849,325 @@ const { data } = await fetchf('https://api.example.com/', { console.error('Request failed:', error); console.error('Request config:', config); }, + onRetry(response, attempt) { + // Log retry attempts for monitoring and debugging + console.warn( + `Retrying request (attempt ${attempt + 1}):`, + response.config.url, + ); + + // Modify config for the upcoming retry request + response.config.headers['Authorization'] = 'Bearer your-new-token'; + + // Log error details for failed attempts + if (response.error) { + console.warn( + `Retry reason: ${response.error.status} - ${response.error.statusText}`, + ); + } + + // You can implement custom retry logic or monitoring here + // For example, send retry metrics to your analytics service + }, + retry: { + retries: 3, + delay: 1000, + backoff: 1.5, + }, }); ``` ### Configuration -The following options are available for configuring interceptors in the `RequestHandler`: +The following options are available for configuring interceptors in the `fetchff` settings: -- **`onRequest`**: +- **`onRequest(config) => config`**: Type: `RequestInterceptor | RequestInterceptor[]` - A function or an array of functions that are invoked before sending a request. Each function receives the request configuration object as its argument, allowing you to modify request parameters, headers, or other settings. - _Default:_ `(config) => config` (no modification). + A function or an array of functions that are invoked before sending a request. Each function receives the request configuration object as its argument, allowing you to modify request parameters, headers, or other settings. + _Default:_ `undefined` (no modification). -- **`onResponse`**: +- **`onResponse(response) => response`**: Type: `ResponseInterceptor | ResponseInterceptor[]` A function or an array of functions that are invoked when a response is received. Each function receives the full response object, enabling you to process the response, handle status codes, or parse data as needed. - _Default:_ `(response) => response` (no modification). + _Default:_ `undefined` (no modification). -- **`onError`**: +- **`onError(error) => error`**: Type: `ErrorInterceptor | ErrorInterceptor[]` A function or an array of functions that handle errors when a request fails. Each function receives the error and request configuration as arguments, allowing you to implement custom error handling logic or logging. - _Default:_ `(error) => error` (no modification). + _Default:_ `undefined` (no modification). -### How It Works +- **`onRetry(response, attempt) => response`**: + Type: `RetryInterceptor | RetryInterceptor[]` + A function or an array of functions that are invoked before each retry attempt. Each function receives the response object (containing error information) and the current attempt number as arguments, allowing you to implement custom retry logging, monitoring, or conditional retry logic. + _Default:_ `undefined` (no retry interception). -1. **Request Interception**: - Before a request is sent, the `onRequest` interceptors are invoked. These interceptors can modify the request configuration, such as adding headers or changing request parameters. +All interceptors are asynchronous and can modify the provided config or response objects. You don't have to return a value, but if you do, any returned properties will be merged into the original argument. -2. **Response Interception**: - Once a response is received, the `onResponse` interceptors are called. These interceptors allow you to handle the response data, process status codes, or transform the response before it is returned to the caller. +### Interceptor Execution Order -3. **Error Interception**: - If a request fails and an error occurs, the `onError` interceptors are triggered. These interceptors provide a way to handle errors, such as logging or retrying requests, based on the error and the request configuration. +`fetchff` follows specific execution patterns for interceptor chains: -4. **Custom Handling**: - Each interceptor function provides a flexible way to customize request and response behavior. You can use these functions to integrate with other systems, handle specific cases, or modify requests and responses as needed. +#### **Request Interceptors: FIFO (First In, First Out)** - +Request interceptors execute in the order they are defined - from global to specific: -## đŸ—„ī¸ Cache Management +```typescript +// Execution order: 1 → 2 → 3 → 4 +const api = createApiFetcher({ + onRequest: (config) => { + /* 1. Global interceptor */ + }, + endpoints: { + getData: { + onRequest: (config) => { + /* 2. Endpoint interceptor */ + }, + }, + }, +}); -
- Click to expand -
- The caching mechanism in fetchf() and createApiFetcher() enhances performance by reducing redundant network requests and reusing previously fetched data when appropriate. This system ensures that cached responses are managed efficiently and only used when considered "fresh". Below is a breakdown of the key parameters that control caching behavior and their default values. -

+await api.getData({ + onRequest: (config) => { + /* 3. Request interceptor */ + }, +}); +``` -> âš ī¸ **When using in Node.js:** -> Cache and deduplication are in-memory and per-process. For distributed or serverless environments, consider external caching if persistence is needed. +#### **Response Interceptors: LIFO (Last In, First Out)** -### Example +Response interceptors execute in reverse order - from specific to global: ```typescript -const { data } = await fetchf('https://api.example.com/', { - cacheTime: 300, // Cache is valid for 5 minutes - cacheKey: (config) => `${config.url}-${config.method}`, // Custom cache key based on URL and method - cacheBuster: (config) => config.method === 'POST', // Bust cache for POST requests - skipCache: (response, config) => response.status !== 200, // Skip caching on non-200 responses +// Execution order: 3 → 2 → 1 +const api = createApiFetcher({ + onResponse: (response) => { + /* 3. Global interceptor (executes last) */ + }, + endpoints: { + getData: { + onResponse: (response) => { + /* 2. Endpoint interceptor */ + }, + }, + }, +}); + +await api.getData({ + onResponse: (response) => { + /* 1. Request interceptor (executes first) */ + }, +}); +``` + +This pattern ensures that: + +- **Request interceptors** can progressively enhance configuration from general to specific +- **Response interceptors** can process data from specific to general, allowing request-level interceptors to handle the response first before global cleanup or logging + +### How It Works + +1. **Request Interception**: + Before a request is sent, the `onRequest` interceptors are invoked. These interceptors can modify the request configuration, such as adding headers or changing request parameters. + +2. **Response Interception**: + Once a response is received, the `onResponse` interceptors are called. These interceptors allow you to handle the response data, process status codes, or transform the response before it is returned to the caller. + +3. **Error Interception**: + If a request fails and an error occurs, the `onError` interceptors are triggered. These interceptors provide a way to handle errors, such as logging or retrying requests, based on the error and the request configuration. + +4. **Custom Handling**: + Each interceptor function provides a flexible way to customize request and response behavior. You can use these functions to integrate with other systems, handle specific cases, or modify requests and responses as needed. + +
+ +## 🌐 Network Revalidation + +
+ Click to expand +
+ +`fetchff` provides intelligent network revalidation features that automatically keep your data fresh based on user interactions and network connectivity. These features help ensure users always see up-to-date information without manual intervention. + +### Focus Revalidation + +When `refetchOnFocus` is enabled, requests are automatically triggered when the browser window regains focus (e.g., when users switch back to your tab). + +```typescript +const { data } = await fetchf('/api/user-profile', { + refetchOnFocus: true, // Revalidate when window gains focus + cacheTime: 300, // Cache for 5 minutes, but still revalidate on focus +}); +``` + +### Network Reconnection Revalidation + +The `refetchOnReconnect` feature automatically revalidates data when the browser detects that internet connectivity has been restored after being offline. + +```typescript +const { data } = await fetchf('/api/notifications', { + refetchOnReconnect: true, // Revalidate when network reconnects + cacheTime: 600, // Cache for 10 minutes, but revalidate when back online +}); +``` + +### Adaptive Timeouts + +`fetchff` automatically adjusts request timeouts based on connection speed to provide optimal user experience: + +```typescript +// Automatically uses: +// - 30 seconds timeout on normal connections +// - 60 seconds timeout on slow connections (2G/3G) +const { data } = await fetchf('/api/data'); + +// You can still override with custom timeout +const { data: customTimeout } = await fetchf('/api/data', { + timeout: 10000, // Force 10 seconds regardless of connection speed +}); + +// Check connection speed manually +import { isSlowConnection } from 'fetchff'; + +if (isSlowConnection()) { + console.log('User is on a slow connection'); + // Adjust your app behavior accordingly +} +``` + +### How It Works + +1. **Event Listeners**: `fetchff` automatically attaches global event listeners for `focus` and `online` events when needed +2. **Background Revalidation**: Network revalidation uses background requests that don't show loading states to users +3. **Automatic Cleanup**: Event listeners are properly managed and cleaned up to prevent memory leaks +4. **Smart Caching**: Revalidation works alongside caching - fresh data updates the cache for future requests +5. **Stale-While-Revalidate**: Use `staleTime` to control when background revalidation happens automatically +6. **Connection Awareness**: Automatically detects connection speed and adjusts timeouts for better reliability + +### Configuration Options + +Both revalidation features can be configured globally or per-request, and work seamlessly with cache timing: + +```typescript +import { createApiFetcher } from 'fetchff'; + +const api = createApiFetcher({ + baseURL: 'https://api.example.com', + // Global settings apply to all endpoints + refetchOnFocus: true, + refetchOnReconnect: true, + cacheTime: 300, // Cache for 5 minutes + staleTime: 60, // Consider fresh for 1 minute, then background revalidate + endpoints: { + getCriticalData: { + url: '/critical-data', + // Override global settings for specific endpoints + refetchOnFocus: true, + refetchOnReconnect: true, + staleTime: 30, // More aggressive background revalidation for critical data + }, + getStaticData: { + url: '/static-data', + // Disable revalidation for static data + refetchOnFocus: false, + refetchOnReconnect: false, + staleTime: 3600, // Background revalidate after 1 hour + }, + }, +}); +``` + +### Use Cases + +**Focus Revalidation** is ideal for: + +- Real-time dashboards and analytics +- Social media feeds and chat applications +- Financial data and trading platforms +- Any data that changes frequently while users are away + +**Reconnection Revalidation** is perfect for: + +- Mobile applications with intermittent connectivity +- Offline-capable applications +- Critical data that must be current when online +- Applications used in areas with unstable internet + +### Best Practices + +1. **Combine with appropriate cache and stale times**: + + ```typescript + const { data } = await fetchf('/api/live-data', { + cacheTime: 300, // Cache for 5 minutes + staleTime: 30, // Consider fresh for 30 seconds + refetchOnFocus: true, // Also revalidate on focus + refetchOnReconnect: true, + }); + ``` + +2. **Use `staleTime` for automatic background updates** - Data stays fresh without user interaction: + + ```typescript + // Good: Automatic background revalidation for dynamic data + const { data: notifications } = await fetchf('/api/notifications', { + cacheTime: 600, // Cache for 10 minutes + staleTime: 60, // Background revalidate after 1 minute + refetchOnFocus: true, + }); + + // Good: Less frequent updates for semi-static data + const { data: userProfile } = await fetchf('/api/profile', { + cacheTime: 1800, // Cache for 30 minutes + staleTime: 600, // Background revalidate after 10 minutes + refetchOnReconnect: true, + }); + ``` + +3. **Use selectively** - Don't enable for all requests to avoid unnecessary network traffic: + + ```typescript + // Good: Enable for critical, changing data + const { data: userNotifications } = await fetchf('/api/notifications', { + refetchOnFocus: true, + refetchOnReconnect: true, + }); + + // Avoid: Don't enable for static configuration data + const { data: appConfig } = await fetchf('/api/config', { + cacheTime: 3600, // Cache for 1 hour + staleTime: 0, // Disable background revalidation + refetchOnFocus: false, + refetchOnReconnect: false, + }); + ``` + +4. **Consider user experience** - Network revalidation happens silently in the background, providing smooth UX without loading spinners. + +> âš ī¸ **Browser Support**: These features work in all modern browsers that support the `focus` and `online` events. In server-side environments (Node.js), these options are safely ignored. + +
+ +## đŸ—„ī¸ Cache Management + +
+ Click to expand +
+ The caching mechanism in fetchf() and createApiFetcher() enhances performance by reducing redundant network requests and reusing previously fetched data when appropriate. This system ensures that cached responses are managed efficiently and only used when considered "fresh". Below is a breakdown of the key parameters that control caching behavior and their default values. +

+ +> âš ī¸ **When using in Node.js:** +> Cache and deduplication are in-memory and per-process. For distributed or serverless environments, consider external caching if persistence is needed. + +### Example + +```typescript +const { data } = await fetchf('https://api.example.com/', { + cacheTime: 300, // Cache is valid for 5 minutes, set -1 for indefinite cache. By default no cache. + cacheKey: (config) => `${config.url}-${config.method}`, // Custom cache key based on URL and method, default automatically generated + cacheBuster: (config) => config.method === 'POST', // Bust cache for POST requests, by default no busting. + skipCache: (response, config) => response.status !== 200, // Skip caching on non-200 responses, by default no skipping + cacheErrors: false, // Cache error responses as well as successful ones, default false + staleTime: 600, // Data is considered fresh for 10 minutes before background revalidation (0 by default, meaning no background revalidation) }); ``` @@ -447,13 +1177,27 @@ The caching system can be fine-tuned using the following options when configurin - **`cacheTime`**: Type: `number` - Specifies the duration, in seconds, for which a cache entry is considered "fresh." Once this time has passed, the entry is considered stale and may be refreshed with a new request. - _Default:_ `0` (no caching). + Specifies the duration, in seconds, for which a cache entry is considered "fresh." Once this time has passed, the entry is considered stale and may be refreshed with a new request. Set to -1 for indefinite cache. + _Default:_ `undefined` (no caching). - **`cacheKey`**: - Type: `CacheKeyFunction` - A function used to generate a custom cache key for the request. If not provided, a default key is created by hashing various parts of the request, including `Method`, `URL`, query parameters, and headers. - _Default:_ Auto-generated based on request properties. + Type: `CacheKeyFunction | string` + A string or function used to generate a custom cache key for the request cache, deduplication etc. If not provided, a default key is created by hashing various parts of the request, including `Method`, `URL`, query parameters, and headers etc. Providing string can help to greatly improve the performance of the requests, avoid unnecessary request flooding etc. + + You can provide either: + - A **string**: Used directly as the cache key for all requests using matching string. + - A **function**: Receives the full request config as an argument and should return a unique string key. This allows you to include any relevant part of the request (such as URL, method, params, body, or custom logic) in the cache key. + + **Example:** + + ```typescript + cacheKey: (config) => + `${config.method}:${config.url}:${JSON.stringify(config.params)}`; + ``` + + This flexibility ensures you can control cache granularity—whether you want to cache per endpoint, per user, or based on any other criteria. + + _Default:_ Auto-generated based on request properties (see below). - **`cacheBuster`**: Type: `CacheBusterFunction` @@ -465,22 +1209,213 @@ The caching system can be fine-tuned using the following options when configurin A function that determines whether caching should be skipped based on the response. This allows for fine-grained control over whether certain responses are cached or not, such as skipping non-`200` responses. _Default:_ `(response, config) => false` (no skipping). -### How It Works +- **`cacheErrors`**: + Type: `boolean` + Determines whether error responses (such as HTTP 4xx or 5xx) should also be cached. If set to `true`, both successful and error responses are stored in the cache. If `false`, only successful responses are cached. + _Default:_ `false`. + +- **`staleTime`**: + Specifies the time in seconds during which cached data is considered "fresh" before it becomes stale and triggers background revalidation (SWR: stale-while-revalidate). + - Set to a number greater than `0` to enable SWR: cached data will be served instantly, and a background request will update the cache after this period. + - Set to `0` to treat data as stale immediately (always eligible for refetch). + - Set to `undefined` to disable SWR: data is never considered stale and background revalidation is not performed. + _Default:_ `undefined` to disable SWR pattern (data is never considered stale) or `300` (5 minutes) in libraries like React. + + ### How It Works + 1. **Cache Lookup**: + When a request is made, `fetchff` first checks the internal cache for a matching entry using the generated cache key. If a valid and "fresh" cache entry exists (within `cacheTime`), the cached response is returned immediately. If the native `fetch()` option `cache: 'reload'` is set, the internal cache is bypassed and a fresh request is made. + + 2. **Cache Key Generation**: + Each request is uniquely identified by a cache key, which is auto-generated from the URL, method, params, headers, and other relevant options. You can override this by providing a custom `cacheKey` string or function for fine-grained cache control. + + 3. **Cache Busting**: + If a `cacheBuster` function is provided, it determines whether to invalidate (bust) the cache for a given request. This is useful for scenarios like forcing fresh data on `POST` requests or after certain actions. + + 4. **Conditional Caching**: + The `skipCache` function allows you to decide, per response, whether it should be stored in the cache. For example, you can skip caching for error responses (like HTTP 4xx/5xx) or based on custom logic. -1. **Request and Cache Check**: - When a request is made, the cache is first checked for an existing entry. If a valid cache entry is found and is still "fresh" (based on `cacheTime`), the cached response is returned immediately. Note that when the native `fetch()` setting called `cache` is set to `reload` the request will automatically skip the internal cache. + 5. **Network Request and Cache Update**: + If no valid cache entry is found, or if caching is skipped or busted, the request is sent to the network. The response is then cached according to your configuration, making it available for future requests. -2. **Cache Key**: - A cache key uniquely identifies each request. By default, the key is generated based on the URL and other relevant request options. Custom keys can be provided using the `cacheKey` function. +### 🔄 Cache and Deduplication Integration -3. **Cache Busting**: - If the `cacheBuster` function is defined, it determines whether to invalidate and refresh the cache for specific requests. This is useful for ensuring that certain requests, such as `POST` requests, always fetch new data. +Understanding how caching works together with request deduplication is crucial for optimal performance: -4. **Skipping Cache**: - The `skipCache` function provides flexibility in deciding whether to store a response in the cache. For example, you might skip caching responses that have a `4xx` or `5xx` status code. +#### **Cache-First, Then Deduplication** -5. **Final Outcome**: - If no valid cache entry is found, or the cache is skipped or busted, the request proceeds to the network, and the response is cached based on the provided configuration. +```typescript +// Multiple components requesting the same data +const userProfile1 = useFetcher('/api/user/123', { cacheTime: 300 }); +const userProfile2 = useFetcher('/api/user/123', { cacheTime: 300 }); +const userProfile3 = useFetcher('/api/user/123', { cacheTime: 300 }); + +// Flow: +// 1. First request checks cache → cache miss → network request initiated +// 2. Second request checks cache → cache miss → joins in-flight request (deduplication) +// 3. Third request checks cache → cache miss → joins in-flight request (deduplication) +// 4. When network response arrives → cache is populated → all requests receive same data +``` + +#### **Cache Hit Scenarios** + +```typescript +// First request (cache miss - goes to network) +const request1 = fetchf('/api/data', { cacheTime: 300, dedupeTime: 5000 }); + +// After 2 seconds - cache hit (no deduplication needed) +setTimeout(() => { + const request2 = fetchf('/api/data', { cacheTime: 300, dedupeTime: 5000 }); + // Returns cached data immediately, no network request +}, 2000); + +// After 10 minutes - cache expired, new request +setTimeout(() => { + const request3 = fetchf('/api/data', { cacheTime: 300, dedupeTime: 5000 }); + // Cache expired → new network request → potential for deduplication again +}, 600000); +``` + +#### **Deduplication Window vs Cache Time** + +- **`dedupeTime`**: Prevents duplicate requests during a short time window (milliseconds) +- **`cacheTime`**: Stores successful responses for longer periods (seconds) +- **Integration**: Deduplication handles concurrent requests, caching handles subsequent requests + +```typescript +const config = { + dedupeTime: 2000, // 2 seconds - for rapid concurrent requests + cacheTime: 300, // 5 minutes - for longer-term storage +}; + +// Timeline example: +// T+0ms: Request A initiated → network call starts +// T+500ms: Request B initiated → joins Request A (deduplication) +// T+1500ms: Request C initiated → joins Request A (deduplication) +// T+2500ms: Request D initiated → deduplication window expired, but cache hit! +// T+6000ms: Request E initiated → cache hit (no network call needed) +``` + +### ⏰ Understanding staleTime vs cacheTime + +The relationship between `staleTime` and `cacheTime` enables sophisticated data freshness strategies: + +#### **Cache States and Timing** + +```typescript +const fetchWithTimings = fetchf('/api/user-feed', { + cacheTime: 600, // Cache for 10 minutes + staleTime: 60, // Consider fresh for 1 minute +}); + +// Data lifecycle: +// T+0: Fresh data - served from cache, no background request +// T+30s: Still fresh - served from cache, no background request +// T+90s: Stale but cached - served from cache + background revalidation +// T+300s: Still stale - served from cache + background revalidation +// T+650s: Cache expired - network request required, shows loading state +``` + +#### **Practical Combinations** + +**High-Frequency Updates (Real-time Data)** + +```typescript +const realtimeData = { + cacheTime: 30, // Cache for 30 seconds + staleTime: 5, // Fresh for 5 seconds only + // Result: Frequent background updates, always responsive UI +}; +``` + +**Balanced Performance (User Data)** + +```typescript +const userData = { + cacheTime: 300, // Cache for 5 minutes + staleTime: 60, // Fresh for 1 minute + // Result: Good performance + reasonable freshness +}; +``` + +**Static Content (Configuration)** + +```typescript +const staticConfig = { + cacheTime: 3600, // Cache for 1 hour + staleTime: 1800, // Fresh for 30 minutes + // Result: Minimal network usage for rarely changing data +}; +``` + +#### **Background Revalidation Behavior** + +```typescript +// When staleTime expires but cacheTime hasn't: +const { data } = await fetchf('/api/notifications', { + cacheTime: 600, // 10 minutes total cache + staleTime: 120, // 2 minutes of "freshness" +}); + +// T+0: Returns cached data immediately, no background request +// T+150s: Returns cached data immediately + triggers background request +// T+150s: Background request completes → cache silently updated +// T+650s: Cache expired → full loading state + network request +``` + +### Auto-Generated Cache Key Properties + +By default, `fetchff` generates a cache key automatically using a combination of the following request properties: + +| Property | Description | Default Value | +| ----------------- | ----------------------------------------------------------------------------------------- | --------------- | +| `method` | The HTTP method used for the request (e.g., GET, POST). | `'GET'` | +| `url` | The full request URL, including the base URL and endpoint path. | `''` | +| `headers` | Request headers, **filtered to include only cache-relevant headers** (see below). | | +| `body` | The request payload (for POST, PUT, PATCH, etc.), stringified if it's an object or array. | | +| `credentials` | Indicates whether credentials (cookies) are included in the request. | `'same-origin'` | +| `params` | Query parameters serialized into the URL (objects, arrays, etc. are stringified). | | +| `urlPathParams` | Dynamic URL path parameters (e.g., `/user/:id`), stringified and encoded. | | +| `withCredentials` | Whether credentials (cookies) are included in the request. | | + +#### Header Filtering for Cache Keys + +To ensure stable cache keys and prevent unnecessary cache misses, `fetchff` only includes headers that affect response content in cache key generation. The following headers are included: + +**Content Negotiation:** + +- `accept` - Affects response format (JSON, HTML, etc.) +- `accept-language` - Affects localization of response +- `accept-encoding` - Affects response compression + +**Authentication & Authorization:** + +- `authorization` - Affects access to protected resources +- `x-api-key` - Token-based access control +- `cookie` - Session-based authentication + +**Request Context:** + +- `content-type` - Affects how request body is interpreted +- `origin` - Relevant for CORS or tenant-specific APIs +- `referer` - May influence API behavior +- `user-agent` - Only if server returns client-specific content + +**Custom Headers:** + +- `x-requested-with` - Distinguishes AJAX requests +- `x-client-id` - Per-client/partner identity +- `x-tenant-id` - Multi-tenant segmentation +- `x-user-id` - Explicit user context +- `x-app-version` - Version-specific behavior +- `x-feature-flag` - Feature rollout controls +- `x-device-id` - Device-specific responses +- `x-platform` - Platform-specific content (iOS, Android, web) +- `x-session-id` - Session-specific responses +- `x-locale` - Locale-specific content + +Headers like `user-agent`, `accept-encoding`, `connection`, `cache-control`, tracking IDs, and proxy-related headers are **excluded** from cache key generation as they don't affect the actual response content. + +These properties are combined and hashed to create a unique cache key for each request. This ensures that requests with different parameters, bodies, or cache-relevant headers are cached separately while maintaining stable cache keys across requests that only differ in non-essential headers. If that does not suffice, you can always use `cacheKey` (string | function) and supply it to particular requests. You can also build your own `cacheKey` function and simply update defaults to reflect it in all requests. Auto key generation would be entirely skipped in such scenarios.
@@ -619,7 +1554,7 @@ The following options are available for configuring polling in the `RequestHandl - **`maxPollingAttempts`**: Type: `number` - Maximum number of polling attempts before stopping. Set to `< 1` for unlimited attempts. + Maximum number of polling attempts before stopping. Set to `0` or negative number for unlimited attempts. _Default:_ `0` (unlimited). - **`shouldStopPolling`**: @@ -659,8 +1594,8 @@ The retry mechanism can be used to handle transient errors and improve the relia const { data } = await fetchf('https://api.example.com/', { retry: { retries: 5, - delay: 100, - maxDelay: 5000, + delay: 100, // Override default adaptive delay (normally 1s/2s based on connection) + maxDelay: 5000, // Override default adaptive maxDelay (normally 30s/60s based on connection) resetTimeout: true, // Resets the timeout for each retry attempt backoff: 1.5, retryOn: [500, 503], @@ -687,6 +1622,11 @@ const { data } = await fetchf('https://api.example.com/', { In this example, the request will retry only on HTTP status codes 500 and 503, as specified in the `retryOn` array. The `resetTimeout` option ensures that the timeout is restarted for each retry attempt. The custom `shouldRetry` function adds further logic: if the server response contains `{"bookId": "none"}`, a retry is forced. Otherwise, the request will retry only if the current attempt number is less than 3. Although the `retries` option is set to 5, the `shouldRetry` function limits the maximum attempts to 3 (the initial request plus 2 retries). +**Note:** When not overridden, `fetchff` automatically adapts retry delays based on connection speed: + +- **Normal connections**: 1s initial delay, 30s max delay +- **Slow connections (2G/3G)**: 2s initial delay, 60s max delay + Additionally, you can handle "Not Found" (404) responses or other specific status codes in your retry logic. For example, you might want to retry when the status text is "Not Found": ```typescript @@ -696,6 +1636,8 @@ shouldRetry(response, attempt) { return true; } // ...other logic + + return null; // Fallback to `retryOn` status code check } ``` @@ -714,13 +1656,13 @@ The retry mechanism is configured via the `retry` option when instantiating the - **`delay`**: Type: `number` - Initial delay (in milliseconds) before the first retry attempt. Subsequent retries use an exponentially increasing delay based on the `backoff` parameter. - _Default:_ `1000` (1 second). + Initial delay (in milliseconds) before the first retry attempt. **Default is adaptive**: 1 second (1000 ms) for normal connections, 2 seconds (2000 ms) on slow connections (2G/3G). Subsequent retries use an exponentially increasing delay based on the `backoff` parameter. + _Default:_ `1000` / `2000` (adaptive based on connection speed). - **`maxDelay`**: Type: `number` - Maximum delay (in milliseconds) between retry attempts. The delay will not exceed this value, even if the exponential backoff would suggest a longer delay. - _Default:_ `30000` (30 seconds). + Maximum delay (in milliseconds) between retry attempts. **Default is adaptive**: 30 seconds (30000 ms) for normal connections, 60 seconds (60000 ms) on slow connections (2G/3G). The delay will not exceed this value, even if the exponential backoff would suggest a longer delay. + _Default:_ `30000` / `60000` (adaptive based on connection speed). - **`backoff`**: Type: `number` @@ -735,7 +1677,6 @@ The retry mechanism is configured via the `retry` option when instantiating the - **`retryOn`**: Type: `number[]` Array of HTTP status codes that should trigger a retry. By default, retries are triggered for the following status codes: - - `408` - Request Timeout - `409` - Conflict - `425` - Too Early @@ -745,17 +1686,18 @@ The retry mechanism is configured via the `retry` option when instantiating the - `503` - Service Unavailable - `504` - Gateway Timeout -- **`shouldRetry(response, currentAttempt)`**: - Type: `RetryFunction` - Function that determines whether a retry should be attempted based on the error from response object (accessed by: response.error), and the current attempt number. This function receives the error object and the attempt number as arguments. - _Default:_ Retry up to the number of specified attempts. +If used in conjunction with `shouldRetry`, the `shouldRetry` function takes priority, and falls back to `retryOn` only if it returns `null`. + +- **`shouldRetry(response: FetchResponse, currentAttempt: Number) => boolean`**: + Type: `RetryFunction` + Function that determines whether a retry should be attempted based on the error or successful response (if `shouldRetry` is provided) object, and the current attempt number. This function receives the error object and the attempt number as arguments. The boolean returned indicates decision. If `true` then it should retry, if `false` then abort and don't retry, if `null` then fallback to `retryOn` status codes check. + _Default:_ `undefined`. ### How It Works 1. **Initial Request**: When a request fails, the retry mechanism captures the failure and checks if it should retry based on the `retryOn` configuration and the result of the `shouldRetry` function. 2. **Retry Attempts**: If a retry is warranted: - - The request is retried up to the specified number of attempts (`retries`). - Each retry waits for a delay before making the next attempt. The delay starts at the initial `delay` value and increases exponentially based on the `backoff` factor, but will not exceed the `maxDelay`. - If `resetTimeout` is enabled, the timeout is reset for each retry attempt. @@ -839,16 +1781,16 @@ You can use the `onResponse` interceptor to customize how the response is handle ```typescript interface FetchResponse< ResponseData = any, + RequestBody = any, QueryParams = any, PathParams = any, - RequestBody = any, > extends Response { data: ResponseData | null; // The parsed response data, or null/defaultResponse if unavailable error: ResponseError< ResponseData, + RequestBody, QueryParams, - PathParams, - RequestBody + PathParams > | null; // Error details if the request failed, otherwise null config: RequestConfig; // The configuration used for the request status: number; // HTTP status code @@ -879,7 +1821,7 @@ The whole response of the native `fetch()` is attached as well. Error object in `error` looks as follows: -- **Type**: `ResponseError | null` +- **Type**: `ResponseError | null` - An object with details about any error that occurred or `null` otherwise. - **`name`**: The name of the error, that is `ResponseError`. @@ -889,6 +1831,7 @@ Error object in `error` looks as follows: - **`request`**: Details about the HTTP request that was sent (e.g., URL, method, headers). - **`config`**: The configuration object used for the request, including URL, method, headers, and query parameters. - **`response`**: The full response object received from the server, including all headers and body. +- **`isCancelled`**: A boolean property on the error object indicating whether the request was cancelled before completion @@ -912,12 +1855,24 @@ The native `fetch()` API function doesn't throw exceptions for HTTP errors like Promises are rejected, and global error handling is triggered. You must use `try/catch` blocks to handle errors. ```typescript +import { fetchf } from 'fetchff'; + try { - const { data } = await fetchf('https://api.example.com/', { - strategy: 'reject', // It is default so it does not really needs to be specified + const { data } = await fetchf('https://api.example.com/users', { + strategy: 'reject', // Default strategy - can be omitted + timeout: 5000, }); + + console.log('Users fetched successfully:', data); } catch (error) { - console.error(error.status, error.statusText, error.response, error.config); + // Handle specific error types + if (error.status === 404) { + console.error('API endpoint not found'); + } else if (error.status >= 500) { + console.error('Server error:', error.statusText); + } else { + console.error('Request failed:', error.message); + } } ``` @@ -929,12 +1884,29 @@ try { > You must always check the error property in the response object to detect and handle errors. ```typescript -const { data, error } = await fetchf('https://api.example.com/', { +import { fetchf } from 'fetchff'; + +const { data, error } = await fetchf('https://api.example.com/users', { strategy: 'softFail', + timeout: 5000, }); if (error) { - console.error(error.status, error.statusText, error.response, error.config); + // Handle errors without try/catch + console.error('Request failed:', { + status: error.status, + message: error.message, + url: error.config?.url, + }); + + // Show user-friendly error message + if (error.status === 429) { + console.log('Rate limited. Please try again later.'); + } else if (error.status >= 500) { + console.log('Server temporarily unavailable. Please try again.'); + } +} else { + console.log('Users fetched successfully:', data); } ``` @@ -948,14 +1920,31 @@ Check `Response Object` section below to see how `error` object is structured. > You must always check the error property in the response object to detect and handle errors. ```typescript -const { data, error } = await fetchf('https://api.example.com/', { - strategy: 'defaultResponse', - defaultResponse: {}, -}); +import { fetchf } from 'fetchff'; + +const { data, error } = await fetchf( + 'https://api.example.com/user-preferences', + { + strategy: 'defaultResponse', + defaultResponse: { + theme: 'light', + language: 'en', + notifications: true, + }, + timeout: 5000, + }, +); if (error) { - console.error('Request failed', data); // "data" will be equal to {} if there is an error + console.warn('Failed to load user preferences, using defaults:', data); + // Log error for debugging but continue with default values + console.error('Preferences API error:', error.message); +} else { + console.log('User preferences loaded:', data); } + +// Safe to use data regardless of error state +document.body.className = data.theme; ``` **`silent`**: @@ -994,6 +1983,209 @@ myLoadingProcess(); 5. **Custom Error Handling**: Depending on the strategy chosen, you can tailor how errors are managed, either by handling them directly within response objects, using default responses, or managing them silently. +### đŸŽ¯ Choosing the Right Error Strategy + +Understanding when to use each error handling strategy is crucial for building robust applications: + +#### **`reject` Strategy - Traditional Error Handling** + +**When to Use:** + +- Building applications with established error boundaries +- Need consistent error propagation through promise chains +- Integration with existing try/catch error handling patterns +- Critical operations where failures must be explicitly handled + +**Best For:** + +```typescript +// API calls where failure must stop execution +try { + const { data } = await fetchf('/api/payment/process', { + method: 'POST', + body: paymentData, + strategy: 'reject', // Default - can be omitted + }); + + // Only proceed if payment succeeded + await processOrderCompletion(data); +} catch (error) { + // Handle payment failure explicitly + showPaymentErrorModal(error.message); + revertOrderState(); +} +``` + +#### **`softFail` Strategy - Graceful Error Handling** + +**When to Use:** + +- Building user-friendly interfaces that degrade gracefully +- Multiple API calls where some failures are acceptable +- React/Vue components that need to handle loading/error states +- Data fetching where partial failures shouldn't break the UI + +**Best For:** + +```typescript +// Dashboard with multiple data sources +const { data: userStats, error: statsError } = await fetchf('/api/user/stats', { + strategy: 'softFail', +}); +const { data: notifications, error: notifError } = await fetchf('/api/notifications', { + strategy: 'softFail', +}); + +// Render what we can, gracefully handle what failed +return ( + + {userStats && } + {statsError && Stats temporarily unavailable} + + {notifications && } + {notifError && Notifications unavailable} + +); +``` + +#### **`defaultResponse` Strategy - Fallback Values** + +**When to Use:** + +- Optional features that should work even when API fails +- Configuration or preferences that have sensible defaults +- Non-critical data that can fall back to static values +- Progressive enhancement scenarios + +**Best For:** + +```typescript +// User preferences with fallbacks +const { data: preferences } = await fetchf('/api/user/preferences', { + strategy: 'defaultResponse', + defaultResponse: { + theme: 'light', + language: 'en', + notifications: true, + autoSave: false, + }, +}); + +// Safe to use preferences regardless of API status +applyTheme(preferences.theme); +setLanguage(preferences.language); +``` + +#### **`silent` Strategy - Fire-and-Forget** + +**When to Use:** + +- Analytics and telemetry data +- Non-critical background operations +- Optional data prefetching +- Logging and monitoring calls + +**Best For:** + +```typescript +// Analytics tracking (don't let failures affect user experience) +const trackUserAction = (action: string, data: any) => { + fetchf('/api/analytics/track', { + method: 'POST', + body: { action, data, timestamp: Date.now() }, + strategy: 'silent', + onError(error) { + // Log error for debugging, but don't disrupt user flow + console.warn('Analytics tracking failed:', error.message); + }, + }); + + // This function never throws, never shows loading states + // User interaction continues uninterrupted +}; + +// Background data prefetching +const prefetchNextPage = () => { + fetchf('/api/articles/page/2', { + strategy: 'silent', + cacheTime: 300, // Cache for later use + }); + // No need to await or handle response +}; +``` + +### 📊 Performance Strategy Matrix + +Choose strategies based on your application's needs: + +| Use Case | Strategy | Benefits | Trade-offs | +| ----------------------- | ----------------- | ------------------------------------------------- | --------------------------------------- | +| **Critical Operations** | `reject` | Explicit error handling, prevents data corruption | Requires try/catch, can break user flow | +| **UI Components** | `softFail` | Graceful degradation, better UX | Need to check error property | +| **Optional Features** | `defaultResponse` | Always provides usable data | May mask real issues | +| **Background Tasks** | `silent` | Never disrupts user experience | Errors may go unnoticed | + +### 🔧 Advanced Strategy Patterns + +#### **Hybrid Error Handling** + +```typescript +// Combine strategies for optimal UX +const fetchUserDashboard = async (userId: string) => { + // Critical user data - must succeed + const { data: userData } = await fetchf(`/api/users/${userId}`, { + strategy: 'reject', + }); + + // Optional widgets - graceful degradation + const { data: stats, error: statsError } = await fetchf( + `/api/users/${userId}/stats`, + { + strategy: 'softFail', + }, + ); + + // Preferences with fallbacks + const { data: preferences } = await fetchf( + `/api/users/${userId}/preferences`, + { + strategy: 'defaultResponse', + defaultResponse: DEFAULT_USER_PREFERENCES, + }, + ); + + // Background analytics - fire and forget + fetchf('/api/analytics/dashboard-view', { + method: 'POST', + body: { userId, timestamp: Date.now() }, + strategy: 'silent', + }); + + return { userData, stats, statsError, preferences }; +}; +``` + +#### **Progressive Enhancement** + +```typescript +// Start with defaults, enhance with API data +const enhanceWithApiData = async () => { + // Immediate render with defaults + let config = DEFAULT_APP_CONFIG; + renderApp(config); + + // Enhance with API data when available + const { data: apiConfig } = await fetchf('/api/config', { + strategy: 'defaultResponse', + defaultResponse: DEFAULT_APP_CONFIG, + }); + + // Re-render with enhanced config + config = { ...config, ...apiConfig }; + renderApp(config); +}; +``` + #### `onError` The `onError` option can be configured to intercept errors: @@ -1051,167 +2243,548 @@ if (error) { The `fetchff` package provides comprehensive TypeScript typings to enhance development experience and ensure type safety. Below are details on the available, exportable types for both `createApiFetcher()` and `fetchf()`. -### Generic Typings +### Typings for `fetchf()` + +The `fetchf()` function includes types that help configure and manage network requests effectively: + +```typescript +interface AddBookRequest { + response: AddBookResponseData; + params: AddBookQueryParams; + urlPathParams: AddBookPathParams; + body: AddBookRequestBody; +} + +// You could also use: fetchf> as a shorthand so not to create additional request interface +const { data: book } = await fetchf('/api/add-book', { + method: 'POST', +}); +// Your book is of type AddBookResponseData +``` + +- **`Req`**: Represents a shorter 4-generics version of request object type for endpoints, allowing you to compose the shape of the request payload, query parameters, and path parameters for each request using a couple inline generics e.g. `fetchf()`. While there is no plan for deprecation, this is for compatibility with older versions only. Aim to use the new method with single generic presented above instead. We don't use overload here to keep it all fast and snappy. +- **`RequestConfig`**: Main configuration options for the `fetchf()` function, including request settings, interceptors, and retry configurations. +- **`RetryConfig`**: Configuration options for retry mechanisms, including the number of retries, delay between retries, and backoff strategies. +- **`CacheConfig`**: Configuration options for caching, including cache time, custom cache keys, and cache invalidation rules. +- **`PollingConfig`**: Configuration options for polling, including polling intervals and conditions to stop polling. +- **`ErrorStrategy`**: Defines strategies for handling errors, such as rejection, soft fail, default response, and silent modes. + +For a complete list of types and their definitions, refer to the [request-handler.ts](https://github.com/MattCCC/fetchff/blob/master/src/types/request-handler.ts) file. + +### Typings for `createApiFetcher()` + +The `createApiFetcher()` function provides a robust set of types to define and manage API interactions. + +- **`EndpointTypes`**: Represents the list of API endpoints with their respective settings. It is your own interface that you can pass to this generic. It will be cross-checked against the `endpoints` object in your `createApiFetcher()` configuration. Each endpoint can be configured with its own specific types such as Response Data Structure, Query Parameters, URL Path Parameters or Request Body. Example: + +```typescript +interface EndpointTypes { + fetchBook: Endpoint<{ + response: Book; + params: BookQueryParams; + urlPathParams: BookPathParams; + }>; + // or shorter version: fetchBook: EndpointReq; + addBook: Endpoint<{ + response: Book; + body: BookBody; + params: BookQueryParams; + urlPathParams: BookPathParams; + }>; + // or shorter version: fetchBook: EndpointReq; + someOtherEndpoint: Endpoint; // The generic is fully optional but it must be defined for endpoint not to output error +} + +const api = createApiFetcher({ + baseURL: 'https://example.com/api', + endpoints: { + fetchBook: { + url: '/get-book', + }, + addBook: { + url: '/add-book', + method: 'POST', + }, + }, +}); + +const { data: book } = await api.addBook(); +// book will be of type Book +``` + +
+ +- **`Endpoint<{response: ResponseData, params: QueryParams, urlPathParams: PathParams, body: RequestBody}>`**: Represents an API endpoint function, allowing to be defined with optional response data (default `DefaultResponse`), query parameters (default `QueryParams`), URL path parameters (default `DefaultUrlParams`), and request body (default `DefaultPayload`). +- **`RequestInterceptor`**: Function to modify request configurations before they are sent. +- **`ResponseInterceptor`**: Function to process responses before they are handled by the application. +- **`ErrorInterceptor`**: Function to handle errors when a request fails. +- **`CustomFetcher`**: Represents the custom `fetcher` function. + +For a full list of types and detailed definitions, refer to the [api-handler.ts](https://github.com/MattCCC/fetchff/blob/master/src/types/api-handler.ts) file. + +### Generic Typings + +The `fetchff` package includes several generic types to handle various aspects of API requests and responses: + +- **`QueryParams`**: Represents query parameters for requests. Can be an object, `URLSearchParams`, an array of name-value pairs, or `null`. +- **`BodyPayload`**: Represents the request body. Can be `BodyInit`, an object, an array, a string, or `null`. +- **`UrlPathParams`**: Represents URL path parameters. Can be an object or `null`. +- **`DefaultResponse`**: Default response for all requests. Default is: `any`. + +### Benefits of Using Typings + +- **Type Safety**: Ensures that configurations and requests adhere to expected formats, reducing runtime errors and improving reliability. +- **Autocompletion**: Provides better support for autocompletion in editors, making development faster and more intuitive. +- **Documentation**: Helps in understanding available options and their expected values, improving code clarity and maintainability. + + + +## 🔒 Sanitization + +
+ Click to expand +
+ +FetchFF includes robust built-in sanitization mechanisms that protect your application from common security vulnerabilities. These safeguards are automatically applied without requiring any additional configuration. + +### Prototype Pollution Prevention + +The library implements automatic protection against prototype pollution attacks by: + +- Removing dangerous properties like `__proto__`, `constructor`, and `prototype` from objects +- Sanitizing all user-provided data before processing it + +```typescript +// Example of protection against prototype pollution +const userInput = { + id: 123, + __proto__: { malicious: true }, +}; + +// The sanitization happens automatically +const response = await fetchf('/api/users', { + params: userInput, // The __proto__ property will be removed +}); +``` + +### Input Sanitization Features + +1. **Object Sanitization** + - All incoming objects are sanitized via the `sanitizeObject` utility + - Creates shallow copies of input objects with dangerous properties removed + - Applied automatically to request configurations, headers, and other objects + +2. **URL Parameter Safety** + - Path parameters are properly encoded using `encodeURIComponent` + - Query parameters are safely serialized and encoded + - Prevents URL injection attacks and ensures valid URL formatting + +3. **Data Validation** + - Checks for JSON serializability of request bodies + - Detects circular references that could cause issues + - Properly handles different data types (strings, arrays, objects, etc.) + +4. **Depth Control** + - Prevents excessive recursion with depth limitations + - Mitigates stack overflow attacks through query parameter manipulation + - Maximum depth is controlled by `MAX_DEPTH` constant (default: 10) + +### Implementation Details + +The sanitization process is applied at multiple levels: + +- During request configuration building +- When processing URL path parameters +- When serializing query parameters +- When handling request and response interceptors +- During retry and polling operations + +This multi-layered approach ensures that all data passing through the library is properly sanitized, significantly reducing the risk of injection attacks and other security vulnerabilities. + +```typescript +// Example of safe URL path parameter handling +const { data } = await api.getUser({ + urlPathParams: { + id: 'user-id with spaces & special chars', + }, + // Automatically encoded to: /users/user-id%20with%20spaces%20%26%20special%20chars +}); +``` + +Security is a core design principle of FetchFF, with sanitization mechanisms running automatically to provide protection without adding complexity to your code. + +
+ +## âš›ī¸ React Integration + +
+ Click to expand +
+ +FetchFF offers a high-performance React hook, `useFetcher(url, config)`, for efficient data fetching in React applications. This hook provides built-in caching, automatic request deduplication, comprehensive state management etc. Its API mirrors the native `fetch` and `fetchf(url, config)` signatures, allowing you to pass all standard and advanced configuration options seamlessly. Designed with React best practices in mind, `useFetcher` ensures optimal performance and a smooth developer experience. + +### Basic Usage + +```tsx +import { useFetcher } from 'fetchff/react'; + +function UserProfile({ userId }: { userId: string }) { + const { data, error, isLoading, refetch } = useFetcher( + `/api/users/${userId}`, + ); + + if (isLoading) return
Loading...
; + if (error) return
Error: {error.message}
; + + return ( +
+

{data.name}

+ +
+ ); +} +``` + +### Hook API + +The `useFetcher(url, config)` hook returns an object with the following properties: + +- **`data: ResponseData | null`** + The fetched data, typed as `T` (generic), or `null` if not available. +- **`error: ResponseError | null`** + Error object if the request failed, otherwise `null`. +- **`isLoading: boolean`** + `true` while data is being loaded for the first time or during a fetch. +- **`isFetching: boolean`** + `true` when currently fetching (fetch is in progress). +- **`config: RequestConfig`** + The configuration object used for the request. +- **`headers: Record`** + Response headers from the last successful request. +- **`refetch: (forceRefresh: boolean = true) => Promise | null>`** + Function to manually trigger a new request. It always uses `softFail` strategy and returns a new FetchResponse object. The `forceRefresh` is set to `true` by default - it will bypass cache and force new request and cache refresh. +- **`mutate: (data: ResponseData, settings: MutationSettings) => Promise | null>`** + Function to update cached data directly, by passing new data. The `settings` object contains currently `revalidate` (boolean) property. If set to `true`, a new request will be made after cache and component data are updated. + +### Configuration Options + +All standard FetchFF options are supported, plus React-specific features: + +```tsx +const { data, error, isLoading } = useFetcher('/api/data', { + // Cache for 5 minutes + cacheTime: 300, + + // Deduplicate requests within 2 seconds + dedupeTime: 2000, + + // Revalidate when window regains focus + refetchOnFocus: true, + + // Don't fetch immediately (useful for POST requests; React specific) + immediate: false, + + // Custom error handling + strategy: 'softFail', + + // Request configuration + method: 'POST', + body: { name: 'John' }, + headers: { Authorization: 'Bearer token' }, +}); +``` + +> **Note on `immediate` behavior**: By default, only GET and HEAD requests (RFC 7231 safe methods) trigger automatically when the component mounts. Other HTTP methods like POST, PUT, DELETE require either setting `immediate: true` explicitly or calling `refetch()` manually. This prevents unintended side effects from automatic execution of non-safe HTTP operations. + +### Conditional Requests + +Only fetch when conditions are met (`immediate` option is `true`): + +```tsx +function ConditionalData({ + shouldFetch, + userId, +}: { + shouldFetch: boolean; + userId?: string; +}) { + const { data, isLoading } = useFetcher(`/api/users/${userId}`, { + immediate: shouldFetch && !!userId, + }); + + // Will only fetch when shouldFetch is true and userId exists + return
{data ? data.name : 'No data'}
; +} +``` + +You can also pass `null` as the URL to conditionally skip a request: + +```tsx +function ConditionalData({ + shouldFetch, + userId, +}: { + shouldFetch: boolean; + userId?: string; +}) { + const { data, isLoading } = useFetcher( + shouldFetch && userId ? `/api/users/${userId}` : null, + ); + + // Will only fetch when shouldFetch is true and userId exists + return
{data ? data.name : 'No data'}
; +} +``` + +> **Note:** Passing `null` as the URL to conditionally skip a request is a legacy/deprecated approach (commonly used in SWR plugin). For new code, prefer using the `immediate` option for conditional fetching. The `null` URL method is still supported for backwards compatibility. + +### Dynamic URLs and Parameters + +```tsx +function SearchResults({ query }: { query: string }) { + const { data, isLoading } = useFetcher('/api/search', { + params: { q: query, limit: 10 }, + // Only fetch when query exists + immediate: !!query, + }); -The `fetchff` package includes several generic types to handle various aspects of API requests and responses: + return ( +
+ {isLoading &&
Searching...
} + {data?.results?.map((item) => ( +
{item.title}
+ ))} +
+ ); +} +``` -- **`QueryParams`**: Represents query parameters for requests. Can be an object, `URLSearchParams`, an array of name-value pairs, or `null`. -- **`BodyPayload`**: Represents the request body. Can be `BodyInit`, an object, an array, a string, or `null`. -- **`UrlPathParams`**: Represents URL path parameters. Can be an object or `null`. -- **`DefaultResponse`**: Default response for all requests. Default is: `any`. +### Mutations and Cache Updates -### Typings for `createApiFetcher()` +```tsx +function TodoList() { + const { data: todos, mutate, refetch } = useFetcher('/api/todos'); + + const addTodo = async (text: string) => { + // Optimistically update the cache + const newTodo = { id: Date.now(), text, completed: false }; + mutate([...todos, newTodo]); + + try { + // Make the actual request + await fetchf('/api/todos', { + method: 'POST', + body: { text }, + }); + + // Revalidate to get the real data + refetch(); + } catch (error) { + // Revert on error + mutate(todos); + } + }; -The `createApiFetcher()` function provides a robust set of types to define and manage API interactions. + return ( +
+ {todos?.map((todo) => ( +
{todo.text}
+ ))} + +
+ ); +} +``` -The key types are: +### Error Handling -- **`EndpointsMethods`**: Represents the list of API endpoints with their respective settings. It is your own interface that you can pass to this generic. It will be cross-checked against the `endpoints` object in your `createApiFetcher()` configuration.

Each endpoint can be configured with its own specific settings such as Response Payload, Query Parameters and URL Path Parameters. -- **`Endpoint`**: Represents an API endpoint function, allowing to be defined with optional query parameters, URL path parameters, request configuration (settings), and request body (data). -- **`EndpointsSettings`**: Configuration for API endpoints, including query parameters, URL path parameters, and additional request configurations. Default is `typeof endpoints`. -- **`RequestInterceptor`**: Function to modify request configurations before they are sent. -- **`ResponseInterceptor`**: Function to process responses before they are handled by the application. -- **`ErrorInterceptor`**: Function to handle errors when a request fails. -- **`CreatedCustomFetcherInstance`**: Represents the custom `fetcher` instance created by its `create()` function. +```tsx +function DataWithErrorHandling() { + const { data, error, isLoading, refetch } = useFetcher('/api/data', { + retry: { + retries: 3, + delay: 1000, + backoff: 1.5, + }, + }); -For a full list of types and detailed definitions, refer to the [api-handler.ts](https://github.com/MattCCC/fetchff/blob/docs-update/src/types/api-handler.ts) file. + if (isLoading) return
Loading...
; -### Typings for `fetchf()` + if (error) { + return ( +
+

Error: {error.message}

+ +
+ ); + } -The `fetchf()` function includes types that help configure and manage network requests effectively: + return
{JSON.stringify(data)}
; +} +``` -- **`RequestHandlerConfig`**: Main configuration options for the `fetchf()` function, including request settings, interceptors, and retry configurations. -- **`RetryConfig`**: Configuration options for retry mechanisms, including the number of retries, delay between retries, and backoff strategies. -- **`CacheConfig`**: Configuration options for caching, including cache time, custom cache keys, and cache invalidation rules. -- **`PollingConfig`**: Configuration options for polling, including polling intervals and conditions to stop polling. -- **`ErrorStrategy`**: Defines strategies for handling errors, such as rejection, soft fail, default response, and silent modes. +### Suspense Support -For a complete list of types and their definitions, refer to the [request-handler.ts](https://github.com/MattCCC/fetchff/blob/docs-update/src/types/request-handler.ts) file. +Use with React Suspense for declarative loading states: -### Benefits of Using Typings +```tsx +import { Suspense } from 'react'; -- **Type Safety**: Ensures that configurations and requests adhere to expected formats, reducing runtime errors and improving reliability. -- **Autocompletion**: Provides better support for autocompletion in editors, making development faster and more intuitive. -- **Documentation**: Helps in understanding available options and their expected values, improving code clarity and maintainability. +function DataComponent() { + const { data } = useFetcher('/api/data', { + strategy: 'reject', // Required for Suspense + }); -
+ return
{data.title}
; +} -## 🔒 Sanitization +function App() { + return ( + Loading...}> + + + ); +} +``` -
- Click to expand -
+### TypeScript Support -FetchFF includes robust built-in sanitization mechanisms that protect your application from common security vulnerabilities. These safeguards are automatically applied without requiring any additional configuration. +Full TypeScript support with automatic type inference: -### Prototype Pollution Prevention +```tsx +interface User { + id: number; + name: string; + email: string; +} -The library implements automatic protection against prototype pollution attacks by: +interface UserParams { + include?: string[]; +} -- Removing dangerous properties like `__proto__`, `constructor`, and `prototype` from objects -- Sanitizing all user-provided data before processing it +function UserComponent({ userId }: { userId: string }) { + const { data, error } = useFetcher(`/api/users/${userId}`, { + params: { include: ['profile', 'settings'] } as UserParams, + }); -```typescript -// Example of protection against prototype pollution -const userInput = { - id: 123, - __proto__: { malicious: true }, -}; + // data is automatically typed as User | null + // error is typed as ResponseError | null -// The sanitization happens automatically -const response = await fetchf('/api/users', { - params: userInput, // The __proto__ property will be removed -}); + return
{data?.name}
; +} ``` -### Input Sanitization Features +### Performance Features -1. **Object Sanitization** +- **Automatic deduplication**: Multiple components requesting the same data share a single request +- **Smart caching**: Configurable cache with automatic invalidation +- **Minimal re-renders**: Optimized to prevent unnecessary component updates (relies on native React functionality) +- **Background revalidation**: Keep data fresh without blocking the UI (use `staleTime` setting to control the time) - - All incoming objects are sanitized via the `sanitizeObject` utility - - Creates shallow copies of input objects with dangerous properties removed - - Applied automatically to request configurations, headers, and other objects +### Best Practices -2. **URL Parameter Safety** +1. **Use conditional requests** for dependent data: - - Path parameters are properly encoded using `encodeURIComponent` - - Query parameters are safely serialized and encoded - - Prevents URL injection attacks and ensures valid URL formatting +```tsx +const { data: user } = useFetcher('/api/user'); +const { data: posts } = useFetcher(user ? `/api/users/${user.id}/posts` : null); +``` -3. **Data Validation** +2. **Configure appropriate cache times** based on data volatility: - - Checks for JSON serializability of request bodies - - Detects circular references that could cause issues - - Properly handles different data types (strings, arrays, objects, etc.) +```tsx +// Static data - cache for 1 hour +const { data: config } = useFetcher('/api/config', { cacheTime: 3600 }); -4. **Depth Control** - - Prevents excessive recursion with depth limitations - - Mitigates stack overflow attacks through query parameter manipulation - - Maximum depth is controlled by `MAX_DEPTH` constant (default: 10) +// Dynamic data - cache for 30 seconds +const { data: feed } = useFetcher('/api/feed', { cacheTime: 30 }); +``` -### Implementation Details +3. **Use focus revalidation** for critical data: -The sanitization process is applied at multiple levels: +```tsx +const { data } = useFetcher('/api/critical-data', { + refetchOnFocus: true, +}); +``` -- During request configuration building -- When processing URL path parameters -- When serializing query parameters -- When handling request and response interceptors -- During retry and polling operations +4. **Handle loading and error states** appropriately: -This multi-layered approach ensures that all data passing through the library is properly sanitized, significantly reducing the risk of injection attacks and other security vulnerabilities. +```tsx +const { data, error, isLoading } = useFetcher('/api/data'); -```typescript -// Example of safe URL path parameter handling -const { data } = await api.getUser({ - urlPathParams: { - id: 'user-id with spaces & special chars', - }, - // Automatically encoded to: /users/user-id%20with%20spaces%20%26%20special%20chars -}); +if (isLoading) return ; +if (error) return ; +return ; ``` -Security is a core design principle of FetchFF, with sanitization mechanisms running automatically to provide protection without adding complexity to your code. +5. **Leverage `staleTime` to control background revalidation:** + +```tsx +// Data is considered fresh for 10 minutes; background revalidation happens after +const { data } = useFetcher('/api/notifications', { staleTime: 600 }); +``` + +- Use a longer `staleTime` for rarely changing data to minimize unnecessary network requests. +- Use a shorter `staleTime` for frequently updated data to keep the UI fresh. +- Setting `staleTime: 0` disables the staleTime (default). +- Combine `staleTime` with `cacheTime` for fine-grained cache and revalidation control. +- Adjust `staleTime` per endpoint based on how critical or dynamic the data is.
## Comparison with other libraries -| Feature | fetchff | ofetch | wretch | axios | native fetch() | -| -------------------------------------------------- | ----------- | ----------- | ------------ | ------------ | -------------- | -| **Unified API Client** | ✅ | -- | -- | -- | -- | -| **Smart Request Cache** | ✅ | -- | -- | -- | -- | -| **Automatic Request Deduplication** | ✅ | -- | -- | -- | -- | -| **Custom Fetching Adapter** | ✅ | -- | -- | -- | -- | -| **Built-in Error Handling** | ✅ | -- | ✅ | -- | -- | -| **Customizable Error Handling** | ✅ | -- | ✅ | ✅ | -- | -| **Retries with exponential backoff** | ✅ | -- | -- | -- | -- | -| **Advanced Query Params handling** | ✅ | -- | -- | -- | -- | -| **Custom Retry logic** | ✅ | ✅ | ✅ | -- | -- | -| **Easy Timeouts** | ✅ | ✅ | ✅ | ✅ | -- | -| **Polling Functionality** | ✅ | -- | -- | -- | -- | -| **Easy Cancellation of stale (previous) requests** | ✅ | -- | -- | -- | -- | -| **Default Responses** | ✅ | -- | -- | -- | -- | -| **Custom adapters (fetchers)** | ✅ | -- | -- | ✅ | -- | -| **Global Configuration** | ✅ | -- | ✅ | ✅ | -- | -| **TypeScript Support** | ✅ | ✅ | ✅ | ✅ | ✅ | -| **Built-in AbortController Support** | ✅ | -- | -- | -- | -- | -| **Request Interceptors** | ✅ | ✅ | ✅ | ✅ | -- | -| **Request and Response Transformation** | ✅ | ✅ | ✅ | ✅ | -- | -| **Integration with libraries** | ✅ | ✅ | ✅ | ✅ | -- | -| **Request Queuing** | ✅ | -- | -- | -- | -- | -| **Multiple Fetching Strategies** | ✅ | -- | -- | -- | -- | -| **Dynamic URLs** | ✅ | -- | ✅ | -- | -- | -| **Automatic Retry on Failure** | ✅ | ✅ | -- | ✅ | -- | -| **Automatically handle 429 Retry-After headers** | ✅ | -- | -- | -- | -- | -| **Built-in Input Sanitization** | ✅ | -- | -- | -- | -- | -| **Prototype Pollution Protection** | ✅ | -- | -- | -- | -- | -| **Server-Side Rendering (SSR) Support** | ✅ | ✅ | -- | -- | -- | -| **Minimal Installation Size** | đŸŸĸ (4.3 KB) | 🟡 (6.5 KB) | đŸŸĸ (2.21 KB) | 🔴 (13.7 KB) | đŸŸĸ (0 KB) | +_fetchff uniquely combines advanced input sanitization, prototype pollution protection, unified cache across React and direct fetches, multiple error handling strategies, and a declarative API repository pattern—all in a single lightweight package._ + +| Feature | fetchff | ofetch | wretch | axios | native fetch() | swr | +| -------------------------------------------------- | ----------- | ----------- | ------------ | ------------ | -------------- | --------------- | +| **Unified API Client** | ✅ | -- | -- | -- | -- | -- | +| **Smart Request Cache** | ✅ | -- | -- | -- | -- | ✅ | +| **Automatic Request Deduplication** | ✅ | -- | -- | -- | -- | ✅ | +| **Revalidation on Window Focus** | ✅ | -- | -- | -- | -- | ✅ | +| **Custom Fetching Adapter** | ✅ | -- | -- | -- | -- | ✅ | +| **Built-in Error Handling** | ✅ | -- | ✅ | -- | -- | -- | +| **Customizable Error Handling** | ✅ | -- | ✅ | ✅ | -- | ✅ | +| **Retries with exponential backoff** | ✅ | -- | -- | -- | -- | -- | +| **Advanced Query Params handling** | ✅ | -- | -- | -- | -- | -- | +| **Custom Response Based Retry logic** | ✅ | ✅ | ✅ | -- | -- | -- | +| **Easy Timeouts** | ✅ | ✅ | ✅ | ✅ | -- | -- | +| **Adaptive Timeouts (Connection-aware)** | ✅ | -- | -- | -- | -- | -- | +| **Conditional Polling Functionality** | ✅ | -- | -- | -- | -- | -- | +| **Easy Cancellation of stale (previous) requests** | ✅ | -- | -- | -- | -- | -- | +| **Default Responses** | ✅ | -- | -- | -- | -- | ✅ | +| **Custom adapters (fetchers)** | ✅ | -- | -- | ✅ | -- | ✅ | +| **Global Configuration** | ✅ | -- | ✅ | ✅ | -- | ✅ | +| **TypeScript Support** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| **Built-in AbortController Support** | ✅ | -- | -- | -- | -- | -- | +| **Request Interceptors** | ✅ | ✅ | ✅ | ✅ | -- | -- | +| **Safe deduping + cancellation** | ✅ | -- | -- | -- | -- | -- | +| **Response-based polling decisions** | ✅ | -- | -- | -- | -- | -- | +| **Request/Response Data Transformation** | ✅ | ✅ | ✅ | ✅ | -- | -- | +| **Works with Multiple Frameworks** | ✅ | ✅ | ✅ | ✅ | ✅ | -- | +| **Works across multiple instances or layers** | ✅ | -- | -- | -- | -- | -- (only React) | +| **Concurrent Request Deduplication** | ✅ | -- | -- | -- | -- | ✅ | +| **Flexible Error Handling Strategies** | ✅ | -- | ✅ | ✅ | -- | ✅ | +| **Dynamic URLs with Path and query separation** | ✅ | -- | ✅ | -- | -- | -- | +| **Automatic Retry on Failure** | ✅ | ✅ | -- | ✅ | -- | ✅ | +| **Automatically handle 429 Retry-After headers** | ✅ | -- | -- | -- | -- | -- | +| **Built-in Input Sanitization** | ✅ | -- | -- | -- | -- | -- | +| **Prototype Pollution Protection** | ✅ | -- | -- | -- | -- | -- | +| **RFC 7231 Safe Methods Auto-execution** | ✅ | -- | -- | -- | -- | -- | +| **First Class React Integration** | ✅ | -- | -- | -- | -- | ✅ | +| **Shared cache for React and direct fetches** | ✅ | -- | -- | -- | -- | -- | +| **Per-endpoint and per-request config merging** | ✅ | -- | -- | -- | -- | -- | +| **Declarative API repository pattern** | ✅ | -- | -- | -- | -- | -- | +| **Supports Server-Side Rendering (SSR)** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | +| **SWR Pattern Support** | ✅ | -- | -- | -- | -- | ✅ | +| **Revalidation on Tab Focus** | ✅ | -- | -- | -- | -- | ✅ | +| **Revalidation on Network Reconnect** | ✅ | -- | -- | -- | -- | ✅ | +| **Minimal Installation Size** | đŸŸĸ (5.2 KB) | 🟡 (6.5 KB) | đŸŸĸ (2.21 KB) | 🔴 (13.7 KB) | đŸŸĸ (0 KB) | 🟡 (6.2 KB) | ## âœī¸ Examples -Click to expand particular examples below. You can also check [examples.ts](./docs/examples/examples.ts) for more examples of usage. +Click to expand particular examples below. You can also check [docs/examples/](./docs/examples/) for more examples of usage. ### All Settings @@ -1238,19 +2811,23 @@ const api = createApiFetcher({ flattenResponse: false, // If true, flatten nested response data. defaultResponse: null, // Default response when there is no data or endpoint fails. withCredentials: true, // Pass cookies to all requests. - timeout: 30000, // Request timeout in milliseconds (30s in this example). + timeout: 30000, // Request timeout in milliseconds. Defaults to 30s (60s on slow connections), can be overridden. dedupeTime: 0, // Time window, in milliseconds, during which identical requests are deduplicated (treated as single request). + immediate: false, // If false, disables automatic request on initialization (useful for POST or conditional requests, React-specific) + staleTime: 600, // Data is considered fresh for 10 minutes before background revalidation (disabled by default) pollingInterval: 5000, // Interval in milliseconds between polling attempts. Setting 0 disables polling. pollingDelay: 1000, // Wait 1 second before beginning each polling attempt maxPollingAttempts: 10, // Stop polling after 10 attempts shouldStopPolling: (response, attempt) => false, // Function to determine if polling should stop based on the response. Return true to stop polling, or false to continue. method: 'get', // Default request method. params: {}, // Default params added to all requests. + urlPathParams: {}, // Dynamic URL path parameters for replacing segments like /user/:id data: {}, // Alias for 'body'. Default data passed to POST, PUT, DELETE and PATCH requests. cacheTime: 300, // Cache time in seconds. In this case it is valid for 5 minutes (300 seconds) cacheKey: (config) => `${config.url}-${config.method}`, // Custom cache key based on URL and method cacheBuster: (config) => config.method === 'POST', // Bust cache for POST requests skipCache: (response, config) => response.status !== 200, // Skip caching on non-200 responses + cacheErrors: false, // Cache error responses as well as successful ones, default false onError(error) { // Interceptor on error console.error('Request failed', error); @@ -1274,9 +2851,9 @@ const api = createApiFetcher({ }, retry: { retries: 3, // Number of retries on failure. - delay: 1000, // Initial delay between retries in milliseconds. + delay: 1000, // Initial delay between retries in milliseconds. Defaults to 1s (2s on slow connections), can be overridden. backoff: 1.5, // Backoff factor for retry delay. - maxDelay: 30000, // Maximum delay between retries in milliseconds. + maxDelay: 30000, // Maximum delay between retries in milliseconds. Defaults to 30s (60s on slow connections), can be overridden. resetTimeout: true, // Reset the timeout when retrying requests. retryOn: [408, 409, 425, 429, 500, 502, 503, 504], // HTTP status codes to retry on. shouldRetry: async (response, attempts) => { @@ -1369,11 +2946,12 @@ interface Books { } interface BookQueryParams { - newBook: boolean; + newBook?: boolean; + category?: string; } interface BookPathParams { - bookId?: number; + bookId: number; } ``` @@ -1384,37 +2962,66 @@ import { createApiFetcher } from 'fetchff'; const endpoints = { fetchBooks: { - url: 'books', + url: '/books', + method: 'GET' as const, }, fetchBook: { - url: 'books/:bookId', + url: '/books/:bookId', + method: 'GET' as const, }, -}; - -// No need to specify all endpoints types. For example, the "fetchBooks" is inferred automatically. -interface EndpointsList { - fetchBook: Endpoint; +} as const; + +// Define endpoints with proper typing +interface EndpointTypes { + fetchBook: Endpoint<{ + response: Book; + params: BookQueryParams; + urlPathParams: BookPathParams; + }>; + fetchBooks: Endpoint<{ response: Books; params: BookQueryParams }>; } -type EndpointsConfiguration = typeof endpoints; - -const api = createApiFetcher({ - apiUrl: 'https://example.com/api/', +const api = createApiFetcher({ + baseURL: 'https://example.com/api', + strategy: 'softFail', endpoints, }); + +export { api }; +export type { Book, Books, BookQueryParams, BookPathParams }; ``` ```typescript +// Usage with full type safety +import { api, type Book, type Books } from './api'; + +// Properly typed request with URL params const book = await api.fetchBook({ params: { newBook: true }, urlPathParams: { bookId: 1 }, }); -// Will return an error since "rating" does not exist in "BookQueryParams" -const anotherBook = await api.fetchBook({ params: { rating: 5 } }); +if (book.error) { + console.error('Failed to fetch book:', book.error.message); +} else { + console.log('Book title:', book.data?.title); +} + +// For example, this will cause a TypeScript error as 'rating' doesn't exist in BookQueryParams +// const invalidBook = await api.fetchBook({ +// params: { rating: 5 } +// }); -// You can also pass generic type directly to the request -const books = await api.fetchBooks(); +// Generic type can be passed directly for additional type safety +const books = await api.fetchBooks({ + params: { category: 'fiction' }, +}); + +if (books.error) { + console.error('Failed to fetch books:', books.error.message); +} else { + console.log('Total books:', books.data?.totalResults); +} ``` @@ -1432,13 +3039,11 @@ const endpoints = { getPosts: { url: '/posts/:subject', }, - getUser: { // Generally there is no need to specify method: 'get' for GET requests as it is default one. It can be adjusted using global "method" setting method: 'get', url: '/user-details', }, - updateUserDetails: { method: 'post', url: '/user-details/update/:userId', @@ -1446,14 +3051,30 @@ const endpoints = { }, }; -interface EndpointsList { - getPosts: Endpoint; +interface PostsResponse { + posts: Array<{ id: number; title: string; content: string }>; + totalCount: number; } -type EndpointsConfiguration = typeof endpoints; +interface PostsQueryParams { + additionalInfo?: string; + limit?: number; +} -const api = createApiFetcher({ - apiUrl: 'https://example.com/api', +interface PostsPathParams { + subject: string; +} + +interface EndpointTypes { + getPosts: Endpoint<{ + response: PostsResponse; + params: PostsQueryParams; + urlPathParams: PostsPathParams; + }>; +} + +const api = createApiFetcher({ + baseURL: 'https://example.com/api', endpoints, onError(error) { console.log('Request failed', error); @@ -1464,12 +3085,14 @@ const api = createApiFetcher({ }); // Fetch user data - "data" will return data directly -// GET to: http://example.com/api/user-details?userId=1&ratings[]=1&ratings[]=2 -const { data } = await api.getUser({ params: { userId: 1, ratings: [1, 2] } }); +// GET to: https://example.com/api/user-details?userId=1&ratings[]=1&ratings[]=2 +const { data } = await api.getUser({ + params: { userId: 1, ratings: [1, 2] }, +}); // Fetch posts - "data" will return data directly -// GET to: http://example.com/api/posts/myTestSubject?additionalInfo=something -const { data } = await api.getPosts({ +// GET to: https://example.com/api/posts/test?additionalInfo=something +const { data: postsData } = await api.getPosts({ params: { additionalInfo: 'something' }, urlPathParams: { subject: 'test' }, }); @@ -1498,29 +3121,19 @@ In the example above we fetch data from an API for user with an ID of 1. We also
```typescript -import { createApiFetcher, RequestConfig, FetchResponse } from 'fetchff'; - -// Define the custom fetcher object -const customFetcher = { - create() { - // Create instance here. It will be called at the beginning of every request. - return { - // This function will be called whenever a request is being fired. - request: async (config: RequestConfig): Promise => { - // Implement your custom fetch logic here - const response = await fetch(config.url, config); - // Optionally, process or transform the response - return response; - }, - }; - }, -}; +import { createApiFetcher } from 'fetchff'; // Create the API fetcher with the custom fetcher const api = createApiFetcher({ baseURL: 'https://api.example.com/', retry: retryConfig, - fetcher: customFetcher, // Provide the custom fetcher object directly + // This function will be called whenever a request is being fired. + async fetcher(config) { + // Implement your custom fetch logic here + const response = await fetch(config.url, config); + // Optionally, process or transform the response + return response; + }, endpoints: { getBooks: { url: 'books/all', @@ -1544,7 +3157,7 @@ const api = createApiFetcher({ import { createApiFetcher } from 'fetchff'; const api = createApiFetcher({ - apiUrl: 'https://example.com/api', + baseURL: 'https://example.com/api', endpoints: { sendMessage: { method: 'post', @@ -1563,7 +3176,7 @@ async function sendMessage() { console.log('Message sent successfully'); } catch (error) { - console.log(error); + console.error('Message failed to send:', error.message); } } @@ -1582,7 +3195,7 @@ sendMessage(); import { createApiFetcher } from 'fetchff'; const api = createApiFetcher({ - apiUrl: 'https://example.com/api', + baseURL: 'https://example.com/api', endpoints: { sendMessage: { method: 'post', @@ -1599,9 +3212,9 @@ async function sendMessage() { }); if (error) { - console.error('Request Error', error); + console.error('Request Error', error.message); } else { - console.log('Message sent successfully'); + console.log('Message sent successfully:', data); } } @@ -1620,12 +3233,11 @@ sendMessage(); import { createApiFetcher } from 'fetchff'; const api = createApiFetcher({ - apiUrl: 'https://example.com/api', + baseURL: 'https://example.com/api', endpoints: { sendMessage: { method: 'post', url: '/send-message/:postId', - // You can also specify strategy and other settings in global list of endpoints, but just for this endpoint // strategy: 'defaultResponse', }, @@ -1633,25 +3245,24 @@ const api = createApiFetcher({ }); async function sendMessage() { - const { data } = await api.sendMessage({ + const { data, error } = await api.sendMessage({ body: { message: 'Text' }, urlPathParams: { postId: 1 }, strategy: 'defaultResponse', // null is a default setting, you can change it to empty {} or anything - // defaultResponse: null, + defaultResponse: { status: 'failed', message: 'Default response' }, onError(error) { // Callback is still triggered here - console.log(error); + console.error('API error:', error.message); }, }); - if (data === null) { - // Because of the strategy, if API call fails, it will just return null + if (error) { + console.warn('Message failed to send, using default response:', data); return; } - // You can do something with the response here - console.log('Message sent successfully'); + console.log('Message sent successfully:', data); } sendMessage(); @@ -1669,12 +3280,11 @@ sendMessage(); import { createApiFetcher } from 'fetchff'; const api = createApiFetcher({ - apiUrl: 'https://example.com/api', + baseURL: 'https://example.com/api', endpoints: { sendMessage: { method: 'post', url: '/send-message/:postId', - // You can also specify strategy and other settings in here, just for this endpoint // strategy: 'silent', }, @@ -1687,7 +3297,7 @@ async function sendMessage() { urlPathParams: { postId: 1 }, strategy: 'silent', onError(error) { - console.log(error); + console.error('Silent error logged:', error.message); }, }); @@ -1711,7 +3321,7 @@ sendMessage(); import { createApiFetcher } from 'fetchff'; const api = createApiFetcher({ - apiUrl: 'https://example.com/api', + baseURL: 'https://example.com/api', endpoints: { sendMessage: { method: 'post', @@ -1721,17 +3331,21 @@ const api = createApiFetcher({ }); async function sendMessage() { - await api.sendMessage({ - body: { message: 'Text' }, - urlPathParams: { postId: 1 }, - onError(error) { - console.log('Error', error.message); - console.log(error.response); - console.log(error.config); - }, - }); + try { + await api.sendMessage({ + body: { message: 'Text' }, + urlPathParams: { postId: 1 }, + onError(error) { + console.error('Error intercepted:', error.message); + console.error('Response:', error.response); + console.error('Config:', error.config); + }, + }); - console.log('Message sent successfully'); + console.log('Message sent successfully'); + } catch (error) { + console.error('Final error handler:', error.message); + } } sendMessage(); @@ -1752,11 +3366,11 @@ import { createApiFetcher } from 'fetchff'; // Initialize API fetcher with endpoints const api = createApiFetcher({ + baseURL: 'https://example.com/api', endpoints: { getUser: { url: '/user' }, - createPost: { url: '/post' }, + createPost: { url: '/post', method: 'POST' }, }, - apiUrl: 'https://example.com/api', }); async function fetchUserAndCreatePost(userId: number, postData: any) { @@ -1806,74 +3420,148 @@ app.get('/api/proxy', async (req, res) => { `fetchff` is designed to seamlessly integrate with any popular frameworks like Next.js, libraries like React, Vue, React Query and SWR. It is written in pure JS so you can effortlessly manage API requests with minimal setup, and without any dependencies. -#### Using with React +#### Advanced Caching Strategies
Click to expand
- You can implement a `useFetcher()` hook to handle the data fetching. Since this package has everything included, you don't really need anything more than a simple hook to utilize.

-Create `api.ts` file: +```typescript +import { fetchf, mutate, deleteCache } from 'fetchff'; + +// Example: User dashboard with smart caching +const fetchUserDashboard = async (userId: string) => { + return await fetchf(`/api/users/${userId}/dashboard`, { + cacheTime: 300, // Cache for 5 minutes + staleTime: 60, // Background revalidate after 1 minute + cacheKey: `user-dashboard-${userId}`, // Custom cache key + skipCache: (response) => response.status === 503, // Skip caching on service unavailable + refetchOnFocus: true, // Refresh when user returns to tab + }); +}; -```tsx -import { createApiFetcher } from 'fetchff'; +// Example: Optimistic updates with cache mutations +const updateUserProfile = async (userId: string, updates: any) => { + // Optimistically update cache + const currentData = await fetchf(`/api/users/${userId}`); + await mutate(`/api/users/${userId}`, { ...currentData.data, ...updates }); -export const api = createApiFetcher({ - apiUrl: 'https://example.com/api', - strategy: 'softFail', - endpoints: { - getProfile: { - url: '/profile/:id', - }, - }, -}); + try { + // Make actual API call + const response = await fetchf(`/api/users/${userId}`, { + method: 'PATCH', + body: updates, + }); + + // Update cache with real response + await mutate(`/api/users/${userId}`, response.data, { revalidate: true }); + + return response; + } catch (error) { + // Revert cache on error + await mutate(`/api/users/${userId}`, currentData.data); + throw error; + } +}; + +// Example: Cache invalidation after user logout +const logout = async () => { + await fetchf('/api/auth/logout', { method: 'POST' }); + + // Clear all user-related cache + deleteCache('/api/user*'); + deleteCache('/api/dashboard*'); +}; ``` -Create `useFetcher.ts` file: +
-```tsx -export const useFetcher = (apiFunction) => { - const [data, setData] = useState(null); - const [error] = useState(null); - const [isLoading, setLoading] = useState(true); +#### Real-time Polling Implementation - useEffect(() => { - const fetchData = async () => { - setLoading(true); +
+ Click to expand +
+ +```typescript +import { fetchf } from 'fetchff'; - const { data, error } = await apiFunction(); +// Example: Job status monitoring with intelligent polling +const monitorJobStatus = async (jobId: string) => { + return await fetchf(`/api/jobs/${jobId}/status`, { + pollingInterval: 2000, // Poll every 2 seconds + pollingDelay: 500, // Wait 500ms before first poll + maxPollingAttempts: 30, // Max 30 attempts (1 minute total) + + shouldStopPolling(response, attempt) { + // Stop polling when job is complete or failed + if ( + response.data?.status === 'completed' || + response.data?.status === 'failed' + ) { + return true; + } - if (error) { - setError(error); - } else { - setData(data); + // Stop if we've been polling for too long + if (attempt >= 30) { + console.warn('Job monitoring timeout after 30 attempts'); + return true; } - setLoading(false); - }; + return false; + }, - fetchData(); - }, [apiFunction]); + onResponse(response) { + console.log(`Job ${jobId} status:`, response.data?.status); - return { data, error, isLoading, setData }; + // Update UI progress if available + if (response.data?.progress) { + updateProgressBar(response.data.progress); + } + }, + }); }; -``` -Call the API in the components: +// Example: Server health monitoring +const monitorServerHealth = async () => { + return await fetchf('/api/health', { + pollingInterval: 30000, // Check every 30 seconds + shouldStopPolling(response, attempt) { + // Never stop health monitoring (until manually cancelled) + return false; + }, -```tsx -export const ProfileComponent = ({ id }) => { - const { - data: profile, - error, - isLoading, - } = useFetcher(() => api.getProfile({ urlPathParams: { id } })); + onResponse(response) { + const isHealthy = response.data?.status === 'healthy'; + updateHealthIndicator(isHealthy); - if (isLoading) return
Loading...
; - if (error) return
Error: {error.message}
; + if (!isHealthy) { + console.warn('Server health check failed:', response.data); + notifyAdmins(response.data); + } + }, - return
{JSON.stringify(profile)}
; + onError(error) { + console.error('Health check failed:', error.message); + updateHealthIndicator(false); + }, + }); }; + +// Helper functions (implementation depends on your UI framework) +function updateProgressBar(progress: number) { + // Update progress bar in UI + console.log(`Progress: ${progress}%`); +} + +function updateHealthIndicator(isHealthy: boolean) { + // Update health indicator in UI + console.log(`Server status: ${isHealthy ? 'Healthy' : 'Unhealthy'}`); +} + +function notifyAdmins(healthData: any) { + // Send notifications to administrators + console.log('Notifying admins about health issue:', healthData); +} ```
@@ -1886,11 +3574,14 @@ export const ProfileComponent = ({ id }) => { Integrate `fetchff` with React Query to streamline your data fetching: +> **Note:** Official support for `useFetcher(url, config)` is here. Check React Integration section above to get an idea how to use it instead of SWR. + ```tsx import { createApiFetcher } from 'fetchff'; +import { useQuery } from '@tanstack/react-query'; const api = createApiFetcher({ - apiUrl: 'https://example.com/api', + baseURL: 'https://example.com/api', endpoints: { getProfile: { url: '/profile/:id', @@ -1898,10 +3589,12 @@ const api = createApiFetcher({ }, }); -export const useProfile = ({ id }) => { - return useQuery(['profile', id], () => - api.getProfile({ urlPathParams: { id } }), - ); +export const useProfile = (id: string) => { + return useQuery({ + queryKey: ['profile', id], + queryFn: () => api.getProfile({ urlPathParams: { id } }), + enabled: !!id, // Only fetch when id exists + }); }; ``` @@ -1915,19 +3608,28 @@ export const useProfile = ({ id }) => { Combine `fetchff` with SWR for efficient data fetching and caching. +> **Note:** Official support for `useFetcher(url, config)` is here. Check React Integration section above to get an idea how to use it instead of SWR. + Single calls: ```typescript -const fetchProfile = ({ id }) => - fetchf('https://example.com/api/profile/:id', { urlPathParams: id }); +import { fetchf } from 'fetchff'; +import useSWR from 'swr'; + +const fetchProfile = (id: string) => + fetchf(`https://example.com/api/profile/${id}`, { + strategy: 'softFail', + }); -export const useProfile = ({ id }) => { - const { data, error } = useSWR(['profile', id], fetchProfile); +export const useProfile = (id: string) => { + const { data, error } = useSWR(id ? ['profile', id] : null, () => + fetchProfile(id), + ); return { - profile: data, + profile: data?.data, isLoading: !error && !data, - isError: error, + isError: error || data?.error, }; }; ``` @@ -1939,7 +3641,7 @@ import { createApiFetcher } from 'fetchff'; import useSWR from 'swr'; const api = createApiFetcher({ - apiUrl: 'https://example.com/api', + baseURL: 'https://example.com/api', endpoints: { getProfile: { url: '/profile/:id', @@ -1947,15 +3649,15 @@ const api = createApiFetcher({ }, }); -export const useProfile = ({ id }) => { - const fetcher = () => api.getProfile({ urlPathParams: { id } }); - - const { data, error } = useSWR(['profile', id], fetcher); +export const useProfile = (id: string) => { + const { data, error } = useSWR(id ? ['profile', id] : null, () => + api.getProfile({ urlPathParams: { id } }), + ); return { - profile: data, + profile: data?.data, isLoading: !error && !data, - isError: error, + isError: error || data?.error, }; }; ``` @@ -1973,7 +3675,7 @@ export const useProfile = ({ id }) => { import { createApiFetcher } from 'fetchff'; const api = createApiFetcher({ - apiUrl: 'https://example.com/api', + baseURL: 'https://example.com/api', strategy: 'softFail', endpoints: { getProfile: { url: '/profile/:id' }, diff --git a/docs/benchmarks/concurrency-react.png b/docs/benchmarks/concurrency-react.png new file mode 100644 index 00000000..5e9ce46b Binary files /dev/null and b/docs/benchmarks/concurrency-react.png differ diff --git a/docs/benchmarks/react-benchmark.png b/docs/benchmarks/react-benchmark.png new file mode 100644 index 00000000..5d81a888 Binary files /dev/null and b/docs/benchmarks/react-benchmark.png differ diff --git a/docs/examples/example-basic.ts b/docs/examples/example-basic.ts new file mode 100644 index 00000000..b1d9c29c --- /dev/null +++ b/docs/examples/example-basic.ts @@ -0,0 +1,27 @@ +import { createApiFetcher } from 'fetchff'; + +const api = createApiFetcher({ + apiUrl: 'https://example.com/api', + endpoints: { + getUser: { + url: '/user-details/:id', + method: 'GET', + }, + getBooks: { + url: '/books/all', + method: 'GET', + }, + }, +}); + +async function main() { + // Basic GET request with path param + const { data: user } = await api.getUser({ urlPathParams: { id: 2 } }); + console.log('User:', user); + + // Basic GET request to fetch all books + const { data: books } = await api.getBooks(); + console.log('Books:', books); +} + +main(); diff --git a/docs/examples/example-custom-headers.ts b/docs/examples/example-custom-headers.ts new file mode 100644 index 00000000..5fef263a --- /dev/null +++ b/docs/examples/example-custom-headers.ts @@ -0,0 +1,24 @@ +import { createApiFetcher } from 'fetchff'; + +const api = createApiFetcher({ + apiUrl: 'https://api.example.com/', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer YOUR_TOKEN', + }, + endpoints: { + getProfile: { + url: '/profile/:id', + }, + }, +}); + +async function main() { + // GET request with custom headers and path param + const { data: profile } = await api.getProfile({ + urlPathParams: { id: 123 }, + }); + console.log('Profile:', profile); +} + +main(); diff --git a/docs/examples/example-error-strategy-defaultResponse.ts b/docs/examples/example-error-strategy-defaultResponse.ts new file mode 100644 index 00000000..0748ab7e --- /dev/null +++ b/docs/examples/example-error-strategy-defaultResponse.ts @@ -0,0 +1,34 @@ +import { createApiFetcher } from 'fetchff'; + +const api = createApiFetcher({ + apiUrl: 'https://example.com/api', + // strategy: 'defaultResponse', + endpoints: { + sendMessage: { + method: 'post', + url: '/send-message/:postId', + // strategy: 'defaultResponse', + }, + }, +}); + +async function sendMessage() { + const { data, error } = await api.sendMessage({ + body: { message: 'Text' }, + urlPathParams: { postId: 1 }, + strategy: 'defaultResponse', + defaultResponse: { status: 'failed', message: 'Default response' }, + onError(error) { + console.error('API error:', error.message); + }, + }); + + if (error) { + console.warn('Message failed to send, using default response:', data); + return; + } + + console.log('Message sent successfully:', data); +} + +sendMessage(); diff --git a/docs/examples/example-error-strategy-reject.ts b/docs/examples/example-error-strategy-reject.ts new file mode 100644 index 00000000..ce33ea7a --- /dev/null +++ b/docs/examples/example-error-strategy-reject.ts @@ -0,0 +1,27 @@ +import { createApiFetcher } from 'fetchff'; +import type { ResponseError } from 'fetchff'; + +const api = createApiFetcher({ + apiUrl: 'https://example.com/api', + endpoints: { + sendMessage: { + method: 'post', + url: '/send-message/:postId', + strategy: 'reject', + }, + }, +}); + +async function sendMessage() { + try { + await api.sendMessage({ + body: { message: 'Text' }, + urlPathParams: { postId: 1 }, + }); + console.log('Message sent successfully'); + } catch (error) { + console.error('Message failed to send:', (error as ResponseError).message); + } +} + +sendMessage(); diff --git a/docs/examples/example-error-strategy-silent.ts b/docs/examples/example-error-strategy-silent.ts new file mode 100644 index 00000000..14001f06 --- /dev/null +++ b/docs/examples/example-error-strategy-silent.ts @@ -0,0 +1,28 @@ +import { createApiFetcher } from 'fetchff'; + +const api = createApiFetcher({ + baseURL: 'https://example.com/api', + endpoints: { + sendMessage: { + method: 'post', + url: '/send-message/:postId', + // strategy: 'silent', + }, + }, +}); + +async function sendMessage() { + await api.sendMessage({ + body: { message: 'Text' }, + urlPathParams: { postId: 1 }, + strategy: 'silent', + onError(error) { + console.error('Silent error logged:', error.message); + }, + }); + + // Because of the strategy, if API call fails, it will never reach this point. Otherwise try/catch would need to be required. + console.log('Message sent successfully'); +} + +sendMessage(); diff --git a/docs/examples/example-error-strategy-softFail.ts b/docs/examples/example-error-strategy-softFail.ts new file mode 100644 index 00000000..c12c69cd --- /dev/null +++ b/docs/examples/example-error-strategy-softFail.ts @@ -0,0 +1,30 @@ +import { createApiFetcher } from 'fetchff'; + +const api = createApiFetcher({ + apiUrl: 'https://example.com/api', + // You can set default strategy for all endpoints + strategy: 'softFail', + endpoints: { + sendMessage: { + method: 'post', + url: '/send-message/:postId', + // You can override strategy for particular endpoint (we set the same here for demonstration) + strategy: 'softFail', + }, + }, +}); + +async function sendMessage() { + const { data, error } = await api.sendMessage({ + body: { message: 'Text' }, + urlPathParams: { postId: 1 }, + }); + + if (error) { + console.error('Request Error', error.message); + } else { + console.log('Message sent successfully:', data); + } +} + +sendMessage(); diff --git a/docs/examples/example-request-chaining.ts b/docs/examples/example-request-chaining.ts new file mode 100644 index 00000000..eda7ae9e --- /dev/null +++ b/docs/examples/example-request-chaining.ts @@ -0,0 +1,32 @@ +import { createApiFetcher } from 'fetchff'; + +const api = createApiFetcher({ + baseURL: 'https://example.com/api', + endpoints: { + getUser: { url: '/user' }, + createPost: { url: '/post', method: 'POST' }, + }, +}); + +interface PostData { + title: string; + content: string; +} + +async function fetchUserAndCreatePost(userId: number, postData: PostData) { + // Fetch user data + const { data: userData } = await api.getUser({ params: { userId } }); + + // Create a new post with the fetched user data + return await api.createPost({ + body: { + ...postData, + userId: userData.id, + }, + }); +} + +// Example usage +fetchUserAndCreatePost(1, { title: 'New Post', content: 'This is a new post.' }) + .then((response) => console.log('Post created:', response)) + .catch((error) => console.error('Error:', error)); diff --git a/docs/examples/example-typescript.ts b/docs/examples/example-typescript.ts new file mode 100644 index 00000000..30ded007 --- /dev/null +++ b/docs/examples/example-typescript.ts @@ -0,0 +1,51 @@ +import { createApiFetcher } from 'fetchff'; +import type { Endpoint } from 'fetchff'; + +// Example endpoint interfaces +type Book = { id: number; title: string; rating: number }; +type Books = { books: Book[]; totalResults: number }; +type BookQueryParams = { newBook?: boolean; category?: string }; +type BookPathParams = { bookId: number }; + +const endpoints = { + fetchBooks: { + url: '/books', + method: 'GET' as const, + }, + fetchBook: { + url: '/books/:bookId', + method: 'GET' as const, + }, +} as const; + +interface EndpointsList { + fetchBook: Endpoint<{ + response: Book; + params: BookQueryParams; + urlPathParams: BookPathParams; + }>; + fetchBooks: Endpoint<{ response: Books; params: BookQueryParams }>; +} + +const api = createApiFetcher({ + baseURL: 'https://example.com/api', + endpoints, + strategy: 'softFail', +}); + +async function main() { + // Properly typed request with URL params + const { data: book } = await api.fetchBook({ + params: { newBook: true }, + urlPathParams: { bookId: 1 }, + }); + console.log('Book:', book); + + // Generic type can be passed directly for additional type safety + const { data: books } = await api.fetchBooks<{ response: Books }>({ + params: { category: 'fiction' }, + }); + console.log('Books:', books); +} + +main(); diff --git a/jest.config.js b/jest.config.js index 3f7d373a..467dd394 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,4 +4,14 @@ module.exports = { testEnvironment: 'node', workerThreads: true, coverageReporters: ['lcov', 'text', 'html'], + coveragePathIgnorePatterns: [ + '/node_modules/', + '/test/utils/', + '/test/mocks/', + '/dist/', + ], + moduleNameMapper: { + '^fetchff$': '/src/index.ts', + '^fetchff/(.*)$': '/src/$1.ts', + }, }; diff --git a/package-lock.json b/package-lock.json index 766c6cab..88b28fa7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,19 +10,27 @@ "license": "UNLICENSED", "devDependencies": { "@size-limit/preset-small-lib": "11.2.0", - "@types/jest": "29.5.14", - "eslint": "9.28.0", + "@testing-library/jest-dom": "6.6.3", + "@testing-library/react": "16.3.0", + "@types/jest": "30.0.0", + "@types/react": "19.1.8", + "benchmark": "2.1.4", + "eslint": "9.30.1", "eslint-config-prettier": "10.1.5", - "eslint-plugin-prettier": "5.4.1", - "fetch-mock": "12.5.2", - "jest": "29.7.0", - "prettier": "3.5.3", + "eslint-plugin-prettier": "5.5.1", + "fetch-mock": "12.5.3", + "globals": "16.3.0", + "jest": "30.0.4", + "jest-environment-jsdom": "30.0.4", + "prettier": "3.6.2", + "react": "19.1.0", + "react-dom": "19.1.0", "size-limit": "11.2.0", - "ts-jest": "29.3.4", + "ts-jest": "29.4.0", "tslib": "2.8.1", "tsup": "8.5.0", "typescript": "5.8.3", - "typescript-eslint": "8.33.0" + "typescript-eslint": "8.36.0" }, "engines": { "node": ">=18" @@ -31,6 +39,13 @@ "@rollup/rollup-linux-x64-gnu": "4.38.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.3.tgz", + "integrity": "sha512-VQKMkwriZbaOgVCby1UDY/LDk5fIjhQicCvVPFqfe+69fWaPWydbWJ3wRt59/YzIwda1I81loas3oCoHxnqvdA==", + "dev": true, + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -45,25 +60,46 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, "node_modules/@babel/code-frame": { - "version": "7.26.2", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", - "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.25.9", + "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/compat-data": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.7.tgz", - "integrity": "sha512-qJzAIcv03PyaWqxRgO4mSU3lihncDT296vnyuE2O8uA4w3UHWI4S3hgeZd1L8W1Bft40w9JxJ2b412iDUFFRhw==", + "version": "7.27.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.5.tgz", + "integrity": "sha512-KiRAp/VoJaWkkte84TvUd9qjdbZAdiqyvMxrGl1N6vzFogKmaLgoM3L1kgtLicp2HP5fBJS8JrZKLVIZGVJAVg==", "dev": true, "license": "MIT", "engines": { @@ -71,22 +107,22 @@ } }, "node_modules/@babel/core": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.7.tgz", - "integrity": "sha512-nykK+LEK86ahTkX/3TgauT0ikKoNCfKHEaZYTUVupJdTLzGNvrblu4u6fa7DhZONAltdf8e662t/abY8idrd/g==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.4.tgz", + "integrity": "sha512-bXYxrXFubeYdvB0NhD/NBB3Qi6aZeV20GOWVI47t2dkecCEoneR4NPVcb7abpXDEvejgrUfFtG6vG/zxAKmg+g==", "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.24.7", - "@babel/helper-compilation-targets": "^7.24.7", - "@babel/helper-module-transforms": "^7.24.7", - "@babel/helpers": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/template": "^7.24.7", - "@babel/traverse": "^7.24.7", - "@babel/types": "^7.24.7", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.3", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.27.3", + "@babel/helpers": "^7.27.4", + "@babel/parser": "^7.27.4", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.27.4", + "@babel/types": "^7.27.3", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -102,31 +138,32 @@ } }, "node_modules/@babel/generator": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.7.tgz", - "integrity": "sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA==", + "version": "7.27.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.5.tgz", + "integrity": "sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.24.7", + "@babel/parser": "^7.27.5", + "@babel/types": "^7.27.3", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^2.5.1" + "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.24.7.tgz", - "integrity": "sha512-ctSdRHBi20qWOfy27RUb4Fhp07KSJ3sXcuSvTrXrc4aG8NSYDo1ici3Vhg9bg69y5bj0Mr1lh0aeEgTvc12rMg==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.24.7", - "@babel/helper-validator-option": "^7.24.7", - "browserslist": "^4.22.2", + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" }, @@ -134,72 +171,30 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-environment-visitor": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.24.7.tgz", - "integrity": "sha512-DoiN84+4Gnd0ncbBOM9AZENV4a5ZiL39HYMyZJGZ/AZEykHYdJw0wW3kdcsh9/Kn+BRXHLkkklZ51ecPKmI1CQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.24.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-function-name": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.24.7.tgz", - "integrity": "sha512-FyoJTsj/PEUWu1/TYRiXTIHc8lbw+TDYkZuoE43opPS5TrI7MyONBE1oNvfguEXAD9yhQRrVBnXdXzSLQl9XnA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.24.7", - "@babel/types": "^7.24.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.24.7.tgz", - "integrity": "sha512-MJJwhkoGy5c4ehfoRyrJ/owKeMl19U54h27YYftT0o2teQ3FJ3nQUf/I3LlJsX4l3qlw7WRXUmiyajvHXoTubQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.24.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-module-imports": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz", - "integrity": "sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.24.7.tgz", - "integrity": "sha512-1fuJEwIrp+97rM4RWdO+qrRsZlAeL1lQJoPqtCYWv0NL115XM93hIH4CSRln2w52SqvmY5hqdtauB6QFCDiZNQ==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", + "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-environment-visitor": "^7.24.7", - "@babel/helper-module-imports": "^7.24.7", - "@babel/helper-simple-access": "^7.24.7", - "@babel/helper-split-export-declaration": "^7.24.7", - "@babel/helper-validator-identifier": "^7.24.7" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.27.3" }, "engines": { "node": ">=6.9.0" @@ -209,46 +204,19 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.7.tgz", - "integrity": "sha512-Rq76wjt7yz9AAc1KnlRKNAi/dMSVWgDRx43FHoJEbcYU6xOWaE2dVPwcdTukJrjxS65GITyfbvEYHvkirZ6uEg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-simple-access": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.7.tgz", - "integrity": "sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.24.7", - "@babel/types": "^7.24.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.24.7.tgz", - "integrity": "sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", "dev": true, "license": "MIT", - "dependencies": { - "@babel/types": "^7.24.7" - }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", - "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, "license": "MIT", "engines": { @@ -256,9 +224,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.25.9", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", - "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", "dev": true, "license": "MIT", "engines": { @@ -266,9 +234,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.7.tgz", - "integrity": "sha512-yy1/KvjhV/ZCL+SM7hBrvnZJ3ZuT9OuZgIJAGpPEToANvc3iM6iDvBnRjtElWibHU6n8/LPR/EjX9EtIEYO3pw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, "license": "MIT", "engines": { @@ -276,27 +244,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.0.tgz", - "integrity": "sha512-U5eyP/CTFPuNE3qk+WZMxFkp/4zUzdceQlfzf7DdGdhp+Fezd7HD+i8Y24ZuTMKX3wQBld449jijbGq6OdGNQg==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.6.tgz", + "integrity": "sha512-muE8Tt8M22638HU31A3CgfSUciwz1fhATfoVai05aPXGor//CdWDCbnlY1yvBPo07njuVOCNGCSp/GTt12lIug==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.27.0", - "@babel/types": "^7.27.0" + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.6" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.0.tgz", - "integrity": "sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==", + "version": "7.27.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.5.tgz", + "integrity": "sha512-OsQd175SxWkGlzbny8J3K8TnnDD0N3lrIUtB92xwyRpzaenGZhxDvxN/JgU00U3CDZNj9tPuDJ5H0WS4Nt3vKg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.27.0" + "@babel/types": "^7.27.3" }, "bin": { "parser": "bin/babel-parser.js" @@ -344,6 +312,38 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-syntax-import-meta": { "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", @@ -371,13 +371,13 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.7.tgz", - "integrity": "sha512-6ddciUPe/mpMnOKv/U+RSd2vvVy+Yw/JfBB0ZHYjEZt9NLHmCUylNYlsbqCCS1Bffjlb0fCwC9Vqz+sBz6PsiQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -464,6 +464,22 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-syntax-top-level-await": { "version": "7.14.5", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", @@ -481,13 +497,13 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.24.7.tgz", - "integrity": "sha512-c/+fVeJBB0FeKsFvwytYiUD+LBvhHjGSI0g446PRGdSVGZLRNArBUno2PETbAly3tpiNAQR5XaZ+JslxkotsbA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -496,36 +512,43 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", + "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.0.tgz", - "integrity": "sha512-2ncevenBqXI6qRMukPlXwHKHchC7RyMuu4xv5JBXRfOGVcTy1mXCD12qrp7Jsoxll1EV3+9sE4GugBVRjT2jFA==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.26.2", - "@babel/parser": "^7.27.0", - "@babel/types": "^7.27.0" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.7.tgz", - "integrity": "sha512-yb65Ed5S/QAcewNPh0nZczy9JdYXkkAbIsEo+P7BE7yO3txAY30Y/oPa3QkQ5It3xVG2kpKMg9MsdxZaO31uKA==", + "version": "7.27.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.4.tgz", + "integrity": "sha512-oNcu2QbHqts9BtOWJosOVJapWjBDSxGCpFvikNR5TGDYDQf3JwpIoMzIKrvfoti93cLfPJEG4tH9SPVeyCGgdA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.24.7", - "@babel/helper-environment-visitor": "^7.24.7", - "@babel/helper-function-name": "^7.24.7", - "@babel/helper-hoist-variables": "^7.24.7", - "@babel/helper-split-export-declaration": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/types": "^7.24.7", + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.27.3", + "@babel/parser": "^7.27.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.3", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -544,14 +567,14 @@ } }, "node_modules/@babel/types": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.0.tgz", - "integrity": "sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==", + "version": "7.27.6", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.6.tgz", + "integrity": "sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.25.9", - "@babel/helper-validator-identifier": "^7.25.9" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -564,140 +587,159 @@ "dev": true, "license": "MIT" }, - "node_modules/@cspotcode/source-map-support": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", - "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "node_modules/@csstools/color-helpers": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.0.2.tgz", + "integrity": "sha512-JqWH1vsgdGcw2RR6VliXXdA0/59LttzlU8UlRT/iUUsEeWfYq8I+K0yhihEUTTHLRm1EXvpsCx3083EU15ecsA==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/trace-mapping": "0.3.9" - }, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", "engines": { - "node": ">=12" + "node": ">=18" } }, - "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", - "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@jridgewell/resolve-uri": "^3.0.3", - "@jridgewell/sourcemap-codec": "^1.4.10" + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" } }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.2.tgz", - "integrity": "sha512-wCIboOL2yXZym2cgm6mlA742s9QeJ8DjGVaL39dLN4rRwrOgOyYSnOaFPhKZGLb2ngj4EyfAFjsNJwPXZvseag==", - "cpu": [ - "ppc64" - ], + "node_modules/@csstools/css-color-parser": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.0.10.tgz", + "integrity": "sha512-TiJ5Ajr6WRd1r8HSiwJvZBiJOqtH86aHpUjq5aEKWHiII2Qfjqd/HCWKPOW8EP4vcspXbHnXrwIDlu5savQipg==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.0.2", + "@csstools/css-calc": "^2.1.4" + }, "engines": { "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" } }, - "node_modules/@esbuild/android-arm": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.2.tgz", - "integrity": "sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==", - "cpu": [ - "arm" - ], + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], + "license": "MIT", "engines": { "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" } }, - "node_modules/@esbuild/android-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.2.tgz", - "integrity": "sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==", - "cpu": [ - "arm64" - ], + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } ], + "license": "MIT", "engines": { "node": ">=18" } }, - "node_modules/@esbuild/android-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.2.tgz", - "integrity": "sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==", - "cpu": [ - "x64" - ], + "node_modules/@emnapi/core": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.4.tgz", + "integrity": "sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" + "dependencies": { + "@emnapi/wasi-threads": "1.0.3", + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.2.tgz", - "integrity": "sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==", - "cpu": [ - "arm64" - ], + "node_modules/@emnapi/runtime": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.4.tgz", + "integrity": "sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.2.tgz", - "integrity": "sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==", - "cpu": [ - "x64" - ], + "node_modules/@emnapi/wasi-threads": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.3.tgz", + "integrity": "sha512-8K5IFFsQqF9wQNJptGbS6FNKgUTsSRYnTqNCG1vPP8jFdjSv18n2mQfJpkt2Oibo9iBEzcDnDxNwKTzC7svlJw==", "dev": true, "license": "MIT", "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" + "dependencies": { + "tslib": "^2.4.0" } }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.2.tgz", - "integrity": "sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==", + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", "cpu": [ "arm64" ], @@ -705,335 +747,42 @@ "license": "MIT", "optional": true, "os": [ - "freebsd" + "darwin" ], "engines": { "node": ">=18" } }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.2.tgz", - "integrity": "sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==", - "cpu": [ - "x64" - ], + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, "engines": { - "node": ">=18" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, - "node_modules/@esbuild/linux-arm": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.2.tgz", - "integrity": "sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==", - "cpu": [ - "arm" - ], + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.2.tgz", - "integrity": "sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.2.tgz", - "integrity": "sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.2.tgz", - "integrity": "sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.2.tgz", - "integrity": "sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.2.tgz", - "integrity": "sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.2.tgz", - "integrity": "sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.2.tgz", - "integrity": "sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.2.tgz", - "integrity": "sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.2.tgz", - "integrity": "sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.2.tgz", - "integrity": "sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.2.tgz", - "integrity": "sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.2.tgz", - "integrity": "sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.2.tgz", - "integrity": "sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.2.tgz", - "integrity": "sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.2.tgz", - "integrity": "sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.2.tgz", - "integrity": "sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^3.4.3" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/@eslint-community/regexpp": { @@ -1047,9 +796,9 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", - "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1062,9 +811,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz", - "integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==", + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1108,10 +857,43 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@eslint/js": { - "version": "9.28.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.28.0.tgz", - "integrity": "sha512-fnqSjGWd/CoIp4EXIxWVK/sHA6DOHN4+8Ix2cX5ycOY7LG0UY8nHCU5pIp2eaE1Mc7Qd8kHspYNzYXT2ojPLzg==", + "version": "9.30.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz", + "integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==", "dev": true, "license": "MIT", "engines": { @@ -1197,121 +979,36 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@humanwhocodes/retry": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.2.tgz", - "integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-regex": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", - "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/@isaacs/cliui/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/@isaacs/cliui/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@isaacs/cliui/node_modules/strip-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", - "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.0.1" - }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=12" + "node": ">=18.18" }, "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" + "type": "github", + "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", "dev": true, - "license": "MIT", + "license": "ISC", "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" }, "engines": { "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/@istanbuljs/load-nyc-config": { @@ -1331,283 +1028,289 @@ "node": ">=8" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", - "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", "dev": true, "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, "engines": { "node": ">=8" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "node_modules/@jest/console": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.0.4.tgz", + "integrity": "sha512-tMLCDvBJBwPqMm4OAiuKm2uF5y5Qe26KgcMn+nrDSWpEW+eeFmqA0iO4zJfL16GP7gE3bUUQ3hIuUJ22AqVRnw==", "dev": true, "license": "MIT", "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" + "@jest/types": "30.0.1", + "@types/node": "*", + "chalk": "^4.1.2", + "jest-message-util": "30.0.2", + "jest-util": "30.0.2", + "slash": "^3.0.0" }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "node_modules/@jest/core": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.0.4.tgz", + "integrity": "sha512-MWScSO9GuU5/HoWjpXAOBs6F/iobvK1XlioelgOM9St7S0Z5WTI9kjCQLPeo4eQRRYusyLW25/J7J5lbFkrYXw==", "dev": true, "license": "MIT", "dependencies": { - "p-locate": "^4.1.0" + "@jest/console": "30.0.4", + "@jest/pattern": "30.0.1", + "@jest/reporters": "30.0.4", + "@jest/test-result": "30.0.4", + "@jest/transform": "30.0.4", + "@jest/types": "30.0.1", + "@types/node": "*", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.0.2", + "jest-config": "30.0.4", + "jest-haste-map": "30.0.2", + "jest-message-util": "30.0.2", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.0.2", + "jest-resolve-dependencies": "30.0.4", + "jest-runner": "30.0.4", + "jest-runtime": "30.0.4", + "jest-snapshot": "30.0.4", + "jest-util": "30.0.2", + "jest-validate": "30.0.2", + "jest-watcher": "30.0.4", + "micromatch": "^4.0.8", + "pretty-format": "30.0.2", + "slash": "^3.0.0" }, "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "node_modules/@jest/core/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, "engines": { - "node": ">=6" + "node": ">=10" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "node_modules/@jest/core/node_modules/pretty-format": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", + "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", "dev": true, "license": "MIT", "dependencies": { - "p-limit": "^2.2.0" + "@jest/schemas": "30.0.1", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@istanbuljs/load-nyc-config/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "node_modules/@jest/core/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } + "license": "MIT" }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", "dev": true, "license": "MIT", "engines": { - "node": ">=8" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/console": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", - "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "node_modules/@jest/environment": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.0.4.tgz", + "integrity": "sha512-5NT+sr7ZOb8wW7C4r7wOKnRQ8zmRWQT2gW4j73IXAKp5/PX1Z8MCStBLQDYfIG3n1Sw0NRfYGdp0iIPVooBAFQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", + "@jest/fake-timers": "30.0.4", + "@jest/types": "30.0.1", "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "slash": "^3.0.0" + "jest-mock": "30.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/core": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", - "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "node_modules/@jest/environment-jsdom-abstract": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/@jest/environment-jsdom-abstract/-/environment-jsdom-abstract-30.0.4.tgz", + "integrity": "sha512-pUKfqgr5Nki9kZ/3iV+ubDsvtPq0a0oNL6zqkKLM1tPQI8FBJeuWskvW1kzc5pOvqlgpzumYZveJ4bxhANY0hg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "^29.7.0", - "@jest/reporters": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/environment": "30.0.4", + "@jest/fake-timers": "30.0.4", + "@jest/types": "30.0.1", + "@types/jsdom": "^21.1.7", "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.7.0", - "jest-config": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-resolve-dependencies": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "jest-watcher": "^29.7.0", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" + "jest-mock": "30.0.2", + "jest-util": "30.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + "canvas": "^3.0.0", + "jsdom": "*" }, "peerDependenciesMeta": { - "node-notifier": { + "canvas": { "optional": true } } }, - "node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "node_modules/@jest/expect": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.0.4.tgz", + "integrity": "sha512-Z/DL7t67LBHSX4UzDyeYKqOxE/n7lbrrgEwWM3dGiH5Dgn35nk+YtgzKudmfIrBI8DRRrKYY5BCo3317HZV1Fw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/node": "*", - "jest-mock": "^29.7.0" + "expect": "30.0.4", + "jest-snapshot": "30.0.4" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "node_modules/@jest/expect-utils": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.0.4.tgz", + "integrity": "sha512-EgXecHDNfANeqOkcak0DxsoVI4qkDUsR7n/Lr2vtmTBjwLPBnnPOF71S11Q8IObWzxm2QgQoY6f9hzrRD3gHRA==", "dev": true, "license": "MIT", "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" + "@jest/get-type": "30.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "node_modules/@jest/fake-timers": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.0.4.tgz", + "integrity": "sha512-qZ7nxOcL5+gwBO6LErvwVy5k06VsX/deqo2XnVUSTV0TNC9lrg8FC3dARbi+5lmrr5VyX5drragK+xLcOjvjYw==", "dev": true, "license": "MIT", "dependencies": { - "jest-get-type": "^29.6.3" + "@jest/types": "30.0.1", + "@sinonjs/fake-timers": "^13.0.0", + "@types/node": "*", + "jest-message-util": "30.0.2", + "jest-mock": "30.0.2", + "jest-util": "30.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "node_modules/@jest/get-type": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.0.1.tgz", + "integrity": "sha512-AyYdemXCptSRFirI5EPazNxyPwAL0jXt3zceFjaj8NFiKP9pOi0bfXonf6qkf82z2t3QWPeLCWWw4stPBzctLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/globals": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.0.4.tgz", + "integrity": "sha512-avyZuxEHF2EUhFF6NEWVdxkRRV6iXXcIES66DLhuLlU7lXhtFG/ySq/a8SRZmEJSsLkNAFX6z6mm8KWyXe9OEA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", - "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "@jest/environment": "30.0.4", + "@jest/expect": "30.0.4", + "@jest/types": "30.0.1", + "jest-mock": "30.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" + "@types/node": "*", + "jest-regex-util": "30.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/reporters": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.0.4.tgz", + "integrity": "sha512-6ycNmP0JSJEEys1FbIzHtjl9BP0tOZ/KN6iMeAKrdvGmUsa1qfRdlQRUDKJ4P84hJ3xHw1yTqJt4fvPNHhyE+g==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", + "@jest/console": "30.0.4", + "@jest/test-result": "30.0.4", + "@jest/transform": "30.0.4", + "@jest/types": "30.0.1", + "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", + "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", + "jest-message-util": "30.0.2", + "jest-util": "30.0.2", + "jest-worker": "30.0.2", "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", + "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" @@ -1619,114 +1322,131 @@ } }, "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.1.tgz", + "integrity": "sha512-+g/1TKjFuGrf1Hh0QPCv0gISwBxJ+MQSNXmG9zjHy7BmFhtoJ9fdNhWJp3qUKRi93AOZHXtdxZgJ1vAtz6z65w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.0.4.tgz", + "integrity": "sha512-BEpX8M/Y5lG7MI3fmiO+xCnacOrVsnbqVrcDZIT8aSGkKV1w2WwvRQxSWw5SIS8ozg7+h8tSj5EO1Riqqxcdag==", "dev": true, "license": "MIT", "dependencies": { - "@sinclair/typebox": "^0.27.8" + "@jest/types": "30.0.1", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/source-map": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", - "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/test-result": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", - "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.0.4.tgz", + "integrity": "sha512-Mfpv8kjyKTHqsuu9YugB6z1gcdB3TSSOaKlehtVaiNlClMkEHY+5ZqCY2CrEE3ntpBMlstX/ShDAf84HKWsyIw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" + "@jest/console": "30.0.4", + "@jest/types": "30.0.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/test-sequencer": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", - "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.0.4.tgz", + "integrity": "sha512-bj6ePmqi4uxAE8EHE0Slmk5uBYd9Vd/PcVt06CsBxzH4bbA8nGsI1YbXl/NH+eii4XRtyrRx+Cikub0x8H4vDg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", + "@jest/test-result": "30.0.4", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.0.2", "slash": "^3.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.0.4.tgz", + "integrity": "sha512-atvy4hRph/UxdCIBp+UB2jhEA/jJiUeGZ7QPgBi9jUUKNgi3WEoMXGNG7zbbELG2+88PMabUNCDchmqgJy3ELg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", + "@babel/core": "^7.27.4", + "@jest/types": "30.0.1", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.0", + "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.0.2", + "jest-regex-util": "30.0.1", + "jest-util": "30.0.2", + "micromatch": "^4.0.8", + "pirates": "^4.0.7", "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" + "write-file-atomic": "^5.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.0.1.tgz", + "integrity": "sha512-HGwoYRVF0QSKJu1ZQX0o5ZrUrrhj0aOOFA8hXrumD7SIzjouevhawbTjmXdwOmURdGluU9DM/XvGm3NyFoiQjw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.1", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", - "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", + "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", "dev": true, "license": "MIT", "dependencies": { @@ -1776,6 +1496,19 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.11.tgz", + "integrity": "sha512-9DPkXtvHydrcOsopiYpUgPHpmj0HWZKMUnL2dZqpvC42lsratuBG06V5ipyno0fUek5VlFsNQ+AcFATSrJXgMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.9.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1826,9 +1559,9 @@ } }, "node_modules/@pkgr/core": { - "version": "0.2.4", - "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.4.tgz", - "integrity": "sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw==", + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.7.tgz", + "integrity": "sha512-YLT9Zo3oNPJoBjBc4q8G2mjU4tqIbf5CEOORbUUr48dCD9q3umJ3IPlVqOqDakPfd2HuwccBaqlGhN4Gmr5OWg==", "dev": true, "license": "MIT", "engines": { @@ -1839,9 +1572,9 @@ } }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.38.0.tgz", - "integrity": "sha512-ldomqc4/jDZu/xpYU+aRxo3V4mGCV9HeTgUBANI3oIQMOL+SsxB+S2lxMpkFp5UamSS3XuTMQVbsS24R4J4Qjg==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.42.0.tgz", + "integrity": "sha512-gldmAyS9hpj+H6LpRNlcjQWbuKUtb94lodB9uCz71Jm+7BxK1VIOo7y62tZZwxhA7j1ylv/yQz080L5WkS+LoQ==", "cpu": [ "arm" ], @@ -1853,9 +1586,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.38.0.tgz", - "integrity": "sha512-VUsgcy4GhhT7rokwzYQP+aV9XnSLkkhlEJ0St8pbasuWO/vwphhZQxYEKUP3ayeCYLhk6gEtacRpYP/cj3GjyQ==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.42.0.tgz", + "integrity": "sha512-bpRipfTgmGFdCZDFLRvIkSNO1/3RGS74aWkJJTFJBH7h3MRV4UijkaEUeOMbi9wxtxYmtAbVcnMtHTPBhLEkaw==", "cpu": [ "arm64" ], @@ -1867,9 +1600,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.38.0.tgz", - "integrity": "sha512-buA17AYXlW9Rn091sWMq1xGUvWQFOH4N1rqUxGJtEQzhChxWjldGCCup7r/wUnaI6Au8sKXpoh0xg58a7cgcpg==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.42.0.tgz", + "integrity": "sha512-JxHtA081izPBVCHLKnl6GEA0w3920mlJPLh89NojpU2GsBSB6ypu4erFg/Wx1qbpUbepn0jY4dVWMGZM8gplgA==", "cpu": [ "arm64" ], @@ -1881,9 +1614,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.38.0.tgz", - "integrity": "sha512-Mgcmc78AjunP1SKXl624vVBOF2bzwNWFPMP4fpOu05vS0amnLcX8gHIge7q/lDAHy3T2HeR0TqrriZDQS2Woeg==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.42.0.tgz", + "integrity": "sha512-rv5UZaWVIJTDMyQ3dCEK+m0SAn6G7H3PRc2AZmExvbDvtaDc+qXkei0knQWcI3+c9tEs7iL/4I4pTQoPbNL2SA==", "cpu": [ "x64" ], @@ -1895,9 +1628,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.38.0.tgz", - "integrity": "sha512-zzJACgjLbQTsscxWqvrEQAEh28hqhebpRz5q/uUd1T7VTwUNZ4VIXQt5hE7ncs0GrF+s7d3S4on4TiXUY8KoQA==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.42.0.tgz", + "integrity": "sha512-fJcN4uSGPWdpVmvLuMtALUFwCHgb2XiQjuECkHT3lWLZhSQ3MBQ9pq+WoWeJq2PrNxr9rPM1Qx+IjyGj8/c6zQ==", "cpu": [ "arm64" ], @@ -1909,9 +1642,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.38.0.tgz", - "integrity": "sha512-hCY/KAeYMCyDpEE4pTETam0XZS4/5GXzlLgpi5f0IaPExw9kuB+PDTOTLuPtM10TlRG0U9OSmXJ+Wq9J39LvAg==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.42.0.tgz", + "integrity": "sha512-CziHfyzpp8hJpCVE/ZdTizw58gr+m7Y2Xq5VOuCSrZR++th2xWAz4Nqk52MoIIrV3JHtVBhbBsJcAxs6NammOQ==", "cpu": [ "x64" ], @@ -1923,9 +1656,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.38.0.tgz", - "integrity": "sha512-mimPH43mHl4JdOTD7bUMFhBdrg6f9HzMTOEnzRmXbOZqjijCw8LA5z8uL6LCjxSa67H2xiLFvvO67PT05PRKGg==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.42.0.tgz", + "integrity": "sha512-UsQD5fyLWm2Fe5CDM7VPYAo+UC7+2Px4Y+N3AcPh/LdZu23YcuGPegQly++XEVaC8XUTFVPscl5y5Cl1twEI4A==", "cpu": [ "arm" ], @@ -1937,9 +1670,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.38.0.tgz", - "integrity": "sha512-tPiJtiOoNuIH8XGG8sWoMMkAMm98PUwlriOFCCbZGc9WCax+GLeVRhmaxjJtz6WxrPKACgrwoZ5ia/uapq3ZVg==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.42.0.tgz", + "integrity": "sha512-/i8NIrlgc/+4n1lnoWl1zgH7Uo0XK5xK3EDqVTf38KvyYgCU/Rm04+o1VvvzJZnVS5/cWSd07owkzcVasgfIkQ==", "cpu": [ "arm" ], @@ -1951,9 +1684,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.38.0.tgz", - "integrity": "sha512-wZco59rIVuB0tjQS0CSHTTUcEde+pXQWugZVxWaQFdQQ1VYub/sTrNdY76D1MKdN2NB48JDuGABP6o6fqos8mA==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.42.0.tgz", + "integrity": "sha512-eoujJFOvoIBjZEi9hJnXAbWg+Vo1Ov8n/0IKZZcPZ7JhBzxh2A+2NFyeMZIRkY9iwBvSjloKgcvnjTbGKHE44Q==", "cpu": [ "arm64" ], @@ -1965,9 +1698,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.38.0.tgz", - "integrity": "sha512-fQgqwKmW0REM4LomQ+87PP8w8xvU9LZfeLBKybeli+0yHT7VKILINzFEuggvnV9M3x1Ed4gUBmGUzCo/ikmFbQ==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.42.0.tgz", + "integrity": "sha512-/3NrcOWFSR7RQUQIuZQChLND36aTU9IYE4j+TB40VU78S+RA0IiqHR30oSh6P1S9f9/wVOenHQnacs/Byb824g==", "cpu": [ "arm64" ], @@ -1979,9 +1712,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.38.0.tgz", - "integrity": "sha512-hz5oqQLXTB3SbXpfkKHKXLdIp02/w3M+ajp8p4yWOWwQRtHWiEOCKtc9U+YXahrwdk+3qHdFMDWR5k+4dIlddg==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.42.0.tgz", + "integrity": "sha512-O8AplvIeavK5ABmZlKBq9/STdZlnQo7Sle0LLhVA7QT+CiGpNVe197/t8Aph9bhJqbDVGCHpY2i7QyfEDDStDg==", "cpu": [ "loong64" ], @@ -1993,9 +1726,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.38.0.tgz", - "integrity": "sha512-NXqygK/dTSibQ+0pzxsL3r4Xl8oPqVoWbZV9niqOnIHV/J92fe65pOir0xjkUZDRSPyFRvu+4YOpJF9BZHQImw==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.42.0.tgz", + "integrity": "sha512-6Qb66tbKVN7VyQrekhEzbHRxXXFFD8QKiFAwX5v9Xt6FiJ3BnCVBuyBxa2fkFGqxOCSGGYNejxd8ht+q5SnmtA==", "cpu": [ "ppc64" ], @@ -2007,9 +1740,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.38.0.tgz", - "integrity": "sha512-GEAIabR1uFyvf/jW/5jfu8gjM06/4kZ1W+j1nWTSSB3w6moZEBm7iBtzwQ3a1Pxos2F7Gz+58aVEnZHU295QTg==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.42.0.tgz", + "integrity": "sha512-KQETDSEBamQFvg/d8jajtRwLNBlGc3aKpaGiP/LvEbnmVUKlFta1vqJqTrvPtsYsfbE/DLg5CC9zyXRX3fnBiA==", "cpu": [ "riscv64" ], @@ -2021,9 +1754,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.38.0.tgz", - "integrity": "sha512-9EYTX+Gus2EGPbfs+fh7l95wVADtSQyYw4DfSBcYdUEAmP2lqSZY0Y17yX/3m5VKGGJ4UmIH5LHLkMJft3bYoA==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.42.0.tgz", + "integrity": "sha512-qMvnyjcU37sCo/tuC+JqeDKSuukGAd+pVlRl/oyDbkvPJ3awk6G6ua7tyum02O3lI+fio+eM5wsVd66X0jQtxw==", "cpu": [ "riscv64" ], @@ -2035,9 +1768,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.38.0.tgz", - "integrity": "sha512-Mpp6+Z5VhB9VDk7RwZXoG2qMdERm3Jw07RNlXHE0bOnEeX+l7Fy4bg+NxfyN15ruuY3/7Vrbpm75J9QHFqj5+Q==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.42.0.tgz", + "integrity": "sha512-I2Y1ZUgTgU2RLddUHXTIgyrdOwljjkmcZ/VilvaEumtS3Fkuhbw4p4hgHc39Ypwvo2o7sBFNl2MquNvGCa55Iw==", "cpu": [ "s390x" ], @@ -2062,9 +1795,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.38.0.tgz", - "integrity": "sha512-q5Zv+goWvQUGCaL7fU8NuTw8aydIL/C9abAVGCzRReuj5h30TPx4LumBtAidrVOtXnlB+RZkBtExMsfqkMfb8g==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.42.0.tgz", + "integrity": "sha512-g86PF8YZ9GRqkdi0VoGlcDUb4rYtQKyTD1IVtxxN4Hpe7YqLBShA7oHMKU6oKTCi3uxwW4VkIGnOaH/El8de3w==", "cpu": [ "x64" ], @@ -2076,9 +1809,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.38.0.tgz", - "integrity": "sha512-u/Jbm1BU89Vftqyqbmxdq14nBaQjQX1HhmsdBWqSdGClNaKwhjsg5TpW+5Ibs1mb8Es9wJiMdl86BcmtUVXNZg==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.42.0.tgz", + "integrity": "sha512-+axkdyDGSp6hjyzQ5m1pgcvQScfHnMCcsXkx8pTgy/6qBmWVhtRVlgxjWwDp67wEXXUr0x+vD6tp5W4x6V7u1A==", "cpu": [ "arm64" ], @@ -2090,9 +1823,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.38.0.tgz", - "integrity": "sha512-mqu4PzTrlpNHHbu5qleGvXJoGgHpChBlrBx/mEhTPpnAL1ZAYFlvHD7rLK839LLKQzqEQMFJfGrrOHItN4ZQqA==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.42.0.tgz", + "integrity": "sha512-F+5J9pelstXKwRSDq92J0TEBXn2nfUrQGg+HK1+Tk7VOL09e0gBqUHugZv7SW4MGrYj41oNCUe3IKCDGVlis2g==", "cpu": [ "ia32" ], @@ -2104,9 +1837,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.38.0.tgz", - "integrity": "sha512-jjqy3uWlecfB98Psxb5cD6Fny9Fupv9LrDSPTQZUROqjvZmcCqNu4UMl7qqhlUUGpwiAkotj6GYu4SZdcr/nLw==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.42.0.tgz", + "integrity": "sha512-LpHiJRwkaVz/LqjHjK8LCi8osq7elmpwujwbXKNW88bM8eeGxavJIKKjkjpMHAh/2xfnrt1ZSnhTv41WYUHYmA==", "cpu": [ "x64" ], @@ -2118,9 +1851,9 @@ ] }, "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "version": "0.34.37", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.37.tgz", + "integrity": "sha512-2TRuQVgQYfy+EzHRTIvkhv2ADEouJ2xNS/Vq+W5EuuewBdOrvATvljZTxHWZSTYr2sTjTHpGvucaGAt67S2akw==", "dev": true, "license": "MIT" }, @@ -2135,13 +1868,13 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "@sinonjs/commons": "^3.0.0" + "@sinonjs/commons": "^3.0.1" } }, "node_modules/@size-limit/esbuild": { @@ -2189,132 +1922,114 @@ "size-limit": "11.2.0" } }, - "node_modules/@swc/core": { - "version": "1.7.26", - "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.7.26.tgz", - "integrity": "sha512-f5uYFf+TmMQyYIoxkn/evWhNGuUzC730dFwAKGwBVHHVoPyak1/GvJUm6i1SKl+2Hrj9oN0i3WSoWWZ4pgI8lw==", + "node_modules/@testing-library/dom": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", + "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "optional": true, + "license": "MIT", "peer": true, "dependencies": { - "@swc/counter": "^0.1.3", - "@swc/types": "^0.1.12" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/swc" - }, - "optionalDependencies": { - "@swc/core-darwin-arm64": "1.7.26", - "@swc/core-darwin-x64": "1.7.26", - "@swc/core-linux-arm-gnueabihf": "1.7.26", - "@swc/core-linux-arm64-gnu": "1.7.26", - "@swc/core-linux-arm64-musl": "1.7.26", - "@swc/core-linux-x64-gnu": "1.7.26", - "@swc/core-linux-x64-musl": "1.7.26", - "@swc/core-win32-arm64-msvc": "1.7.26", - "@swc/core-win32-ia32-msvc": "1.7.26", - "@swc/core-win32-x64-msvc": "1.7.26" + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" }, - "peerDependencies": { - "@swc/helpers": "*" - }, - "peerDependenciesMeta": { - "@swc/helpers": { - "optional": true - } - } - }, - "node_modules/@swc/core-darwin-arm64": { - "version": "1.7.26", - "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.7.26.tgz", - "integrity": "sha512-FF3CRYTg6a7ZVW4yT9mesxoVVZTrcSWtmZhxKCYJX9brH4CS/7PRPjAKNk6kzWgWuRoglP7hkjQcd6EpMcZEAw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 AND MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true, "engines": { - "node": ">=10" + "node": ">=18" } }, - "node_modules/@swc/counter": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", - "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true - }, - "node_modules/@swc/helpers": { - "version": "0.5.13", - "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.13.tgz", - "integrity": "sha512-UoKGxQ3r5kYI9dALKJapMmuK+1zWM/H17Z1+iwnNmzcJRnfFuevZs375TA5rW31pu4BS4NoSy1fRsexDXfWn5w==", + "node_modules/@testing-library/jest-dom": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", + "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, + "license": "MIT", "dependencies": { - "tslib": "^2.4.0" + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" } }, - "node_modules/@swc/types": { - "version": "0.1.12", - "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.12.tgz", - "integrity": "sha512-wBJA+SdtkbFhHjTMYH+dEH1y4VpfGdAc2Kw/LK09i9bXd/K6j6PkDcFCEzb6iVfZMkPRrl/q0e3toqTAJdkIVA==", + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, + "license": "MIT", "dependencies": { - "@swc/counter": "^0.1.3" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" } }, - "node_modules/@tsconfig/node10": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", - "integrity": "sha512-DcRjDCujK/kCk/cUe8Xz8ZSpm8mS3mNNpta+jGCA6USEDfktlNvm1+IuZ9eTcDbNk41BHwpHHeW+N1lKCz4zOw==", + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true + "license": "MIT" }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "node_modules/@testing-library/react": { + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", + "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "node_modules/@tybys/wasm-util": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.9.0.tgz", + "integrity": "sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==", "dev": true, "license": "MIT", "optional": true, - "peer": true + "dependencies": { + "tslib": "^2.4.0" + } }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, "license": "MIT", - "optional": true, "peer": true }, "node_modules/@types/babel__core": { @@ -2332,9 +2047,9 @@ } }, "node_modules/@types/babel__generator": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", - "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "dev": true, "license": "MIT", "dependencies": { @@ -2353,9 +2068,9 @@ } }, "node_modules/@types/babel__traverse": { - "version": "7.20.6", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.6.tgz", - "integrity": "sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==", + "version": "7.20.7", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", + "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", "dev": true, "license": "MIT", "dependencies": { @@ -2363,9 +2078,9 @@ } }, "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true, "license": "MIT" }, @@ -2376,16 +2091,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/graceful-fs": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", - "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -2414,14 +2119,61 @@ } }, "node_modules/@types/jest": { - "version": "29.5.14", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", - "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^30.0.0", + "pretty-format": "^30.0.0" + } + }, + "node_modules/@types/jest/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@types/jest/node_modules/pretty-format": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", + "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.1", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@types/jest/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/jsdom": { + "version": "21.1.7", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-21.1.7.tgz", + "integrity": "sha512-yOriVnggzrnQ3a9OKOCxaVuSug3w3/SbOj5i7VwXWZEyUNl3bLF9V3MfxGbZKuwqJOQyRfqXyROBB1CoZLFWzA==", "dev": true, "license": "MIT", "dependencies": { - "expect": "^29.0.0", - "pretty-format": "^29.0.0" + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" } }, "node_modules/@types/json-schema": { @@ -2432,13 +2184,23 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.14.9", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.14.9.tgz", - "integrity": "sha512-06OCtnTXtWOZBJlRApleWndH4JsRVs1pDCc8dLSQp+7PpUpX3ePdHyeNSFTeSe7FtKyQkrlPvHwJOW3SLd8Oyg==", + "version": "24.0.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.0.0.tgz", + "integrity": "sha512-yZQa2zm87aRVcqDyH5+4Hv9KYgSdgwX1rFnGvpbzMaC7YAljmhBET93TPiTd3ObwTL+gSpIzPKg5BqVxdCvxKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.8.0" + } + }, + "node_modules/@types/react": { + "version": "19.1.8", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", + "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "csstype": "^3.0.2" } }, "node_modules/@types/stack-utils": { @@ -2448,10 +2210,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", "dev": true, "license": "MIT", "dependencies": { @@ -2466,17 +2235,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.33.0.tgz", - "integrity": "sha512-CACyQuqSHt7ma3Ns601xykeBK/rDeZa3w6IS6UtMQbixO5DWy+8TilKkviGDH6jtWCo8FGRKEK5cLLkPvEammQ==", + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.36.0.tgz", + "integrity": "sha512-lZNihHUVB6ZZiPBNgOQGSxUASI7UJWhT8nHyUGCnaQ28XFCw98IfrMCG3rUl1uwUWoAvodJQby2KTs79UTcrAg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.33.0", - "@typescript-eslint/type-utils": "8.33.0", - "@typescript-eslint/utils": "8.33.0", - "@typescript-eslint/visitor-keys": "8.33.0", + "@typescript-eslint/scope-manager": "8.36.0", + "@typescript-eslint/type-utils": "8.36.0", + "@typescript-eslint/utils": "8.36.0", + "@typescript-eslint/visitor-keys": "8.36.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -2490,7 +2259,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.33.0", + "@typescript-eslint/parser": "^8.36.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } @@ -2506,16 +2275,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.33.0.tgz", - "integrity": "sha512-JaehZvf6m0yqYp34+RVnihBAChkqeH+tqqhS0GuX1qgPpwLvmTPheKEs6OeCK6hVJgXZHJ2vbjnC9j119auStQ==", + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.36.0.tgz", + "integrity": "sha512-FuYgkHwZLuPbZjQHzJXrtXreJdFMKl16BFYyRrLxDhWr6Qr7Kbcu2s1Yhu8tsiMXw1S0W1pjfFfYEt+R604s+Q==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.33.0", - "@typescript-eslint/types": "8.33.0", - "@typescript-eslint/typescript-estree": "8.33.0", - "@typescript-eslint/visitor-keys": "8.33.0", + "@typescript-eslint/scope-manager": "8.36.0", + "@typescript-eslint/types": "8.36.0", + "@typescript-eslint/typescript-estree": "8.36.0", + "@typescript-eslint/visitor-keys": "8.36.0", "debug": "^4.3.4" }, "engines": { @@ -2531,14 +2300,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.33.0.tgz", - "integrity": "sha512-d1hz0u9l6N+u/gcrk6s6gYdl7/+pp8yHheRTqP6X5hVDKALEaTn8WfGiit7G511yueBEL3OpOEpD+3/MBdoN+A==", + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.36.0.tgz", + "integrity": "sha512-JAhQFIABkWccQYeLMrHadu/fhpzmSQ1F1KXkpzqiVxA/iYI6UnRt2trqXHt1sYEcw1mxLnB9rKMsOxXPxowN/g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.33.0", - "@typescript-eslint/types": "^8.33.0", + "@typescript-eslint/tsconfig-utils": "^8.36.0", + "@typescript-eslint/types": "^8.36.0", "debug": "^4.3.4" }, "engines": { @@ -2547,17 +2316,20 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.33.0.tgz", - "integrity": "sha512-LMi/oqrzpqxyO72ltP+dBSP6V0xiUb4saY7WLtxSfiNEBI8m321LLVFU9/QDJxjDQG9/tjSqKz/E3380TEqSTw==", + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.36.0.tgz", + "integrity": "sha512-wCnapIKnDkN62fYtTGv2+RY8FlnBYA3tNm0fm91kc2BjPhV2vIjwwozJ7LToaLAyb1ca8BxrS7vT+Pvvf7RvqA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.33.0", - "@typescript-eslint/visitor-keys": "8.33.0" + "@typescript-eslint/types": "8.36.0", + "@typescript-eslint/visitor-keys": "8.36.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2568,9 +2340,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.33.0.tgz", - "integrity": "sha512-sTkETlbqhEoiFmGr1gsdq5HyVbSOF0145SYDJ/EQmXHtKViCaGvnyLqWFFHtEXoS0J1yU8Wyou2UGmgW88fEug==", + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.36.0.tgz", + "integrity": "sha512-Nhh3TIEgN18mNbdXpd5Q8mSCBnrZQeY9V7Ca3dqYvNDStNIGRmJA6dmrIPMJ0kow3C7gcQbpsG2rPzy1Ks/AnA==", "dev": true, "license": "MIT", "engines": { @@ -2585,14 +2357,14 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.33.0.tgz", - "integrity": "sha512-lScnHNCBqL1QayuSrWeqAL5GmqNdVUQAAMTaCwdYEdWfIrSrOGzyLGRCHXcCixa5NK6i5l0AfSO2oBSjCjf4XQ==", + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.36.0.tgz", + "integrity": "sha512-5aaGYG8cVDd6cxfk/ynpYzxBRZJk7w/ymto6uiyUFtdCozQIsQWh7M28/6r57Fwkbweng8qAzoMCPwSJfWlmsg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.33.0", - "@typescript-eslint/utils": "8.33.0", + "@typescript-eslint/typescript-estree": "8.36.0", + "@typescript-eslint/utils": "8.36.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -2609,146 +2381,409 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.33.0.tgz", - "integrity": "sha512-DKuXOKpM5IDT1FA2g9x9x1Ug81YuKrzf4mYX8FAVSNu5Wo/LELHWQyM1pQaDkI42bX15PWl0vNPt1uGiIFUOpg==", + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.36.0.tgz", + "integrity": "sha512-xGms6l5cTJKQPZOKM75Dl9yBfNdGeLRsIyufewnxT4vZTrjC0ImQT4fj8QmtJK84F58uSh5HVBSANwcfiXxABQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.36.0.tgz", + "integrity": "sha512-JaS8bDVrfVJX4av0jLpe4ye0BpAaUW7+tnS4Y4ETa3q7NoZgzYbN9zDQTJ8kPb5fQ4n0hliAt9tA4Pfs2zA2Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.36.0", + "@typescript-eslint/tsconfig-utils": "8.36.0", + "@typescript-eslint/types": "8.36.0", + "@typescript-eslint/visitor-keys": "8.36.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.36.0.tgz", + "integrity": "sha512-VOqmHu42aEMT+P2qYjylw6zP/3E/HvptRwdn/PZxyV27KhZg2IOszXod4NcXisWzPAGSS4trE/g4moNj6XmH2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.36.0", + "@typescript-eslint/types": "8.36.0", + "@typescript-eslint/typescript-estree": "8.36.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.36.0.tgz", + "integrity": "sha512-vZrhV2lRPWDuGoxcmrzRZyxAggPL+qp3WzUrlZD+slFueDiYHxeBa34dUXPuC0RmGKzl4lS5kFJYvKCq9cnNDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.36.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.0.tgz", + "integrity": "sha512-LRw5BW29sYj9NsQC6QoqeLVQhEa+BwVINYyMlcve+6stwdBsSt5UB7zw4UZB4+4PNqIVilHoMaPWCb/KhABHQw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.0.tgz", + "integrity": "sha512-zYX8D2zcWCAHqghA8tPjbp7LwjVXbIZP++mpU/Mrf5jUVlk3BWIxkeB8yYzZi5GpFSlqMcRZQxQqbMI0c2lASQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.0.tgz", + "integrity": "sha512-YsYOT049hevAY/lTYD77GhRs885EXPeAfExG5KenqMJ417nYLS2N/kpRpYbABhFZBVQn+2uRPasTe4ypmYoo3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.0.tgz", + "integrity": "sha512-PSjvk3OZf1aZImdGY5xj9ClFG3bC4gnSSYWrt+id0UAv+GwwVldhpMFjAga8SpMo2T1GjV9UKwM+QCsQCQmtdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.0.tgz", + "integrity": "sha512-KC/iFaEN/wsTVYnHClyHh5RSYA9PpuGfqkFua45r4sweXpC0KHZ+BYY7ikfcGPt5w1lMpR1gneFzuqWLQxsRKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.0.tgz", + "integrity": "sha512-CDh/0v8uot43cB4yKtDL9CVY8pbPnMV0dHyQCE4lFz6PW/+9tS0i9eqP5a91PAqEBVMqH1ycu+k8rP6wQU846w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.0.tgz", + "integrity": "sha512-+TE7epATDSnvwr3L/hNHX3wQ8KQYB+jSDTdywycg3qDqvavRP8/HX9qdq/rMcnaRDn4EOtallb3vL/5wCWGCkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.0.tgz", + "integrity": "sha512-VBAYGg3VahofpQ+L4k/ZO8TSICIbUKKTaMYOWHWfuYBFqPbSkArZZLezw3xd27fQkxX4BaLGb/RKnW0dH9Y/UA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.0.tgz", + "integrity": "sha512-9IgGFUUb02J1hqdRAHXpZHIeUHRrbnGo6vrRbz0fREH7g+rzQy53/IBSyadZ/LG5iqMxukriNPu4hEMUn+uWEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.0.tgz", + "integrity": "sha512-LR4iQ/LPjMfivpL2bQ9kmm3UnTas3U+umcCnq/CV7HAkukVdHxrDD1wwx74MIWbbgzQTLPYY7Ur2MnnvkYJCBQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.0.tgz", + "integrity": "sha512-HCupFQwMrRhrOg7YHrobbB5ADg0Q8RNiuefqMHVsdhEy9lLyXm/CxsCXeLJdrg27NAPsCaMDtdlm8Z2X8x91Tg==", + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.33.0.tgz", - "integrity": "sha512-vegY4FQoB6jL97Tu/lWRsAiUUp8qJTqzAmENH2k59SJhw0Th1oszb9Idq/FyyONLuNqT1OADJPXfyUNOR8SzAQ==", + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.0.tgz", + "integrity": "sha512-Ckxy76A5xgjWa4FNrzcKul5qFMWgP5JSQ5YKd0XakmWOddPLSkQT+uAvUpQNnFGNbgKzv90DyQlxPDYPQ4nd6A==", + "cpu": [ + "riscv64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@typescript-eslint/project-service": "8.33.0", - "@typescript-eslint/tsconfig-utils": "8.33.0", - "@typescript-eslint/types": "8.33.0", - "@typescript-eslint/visitor-keys": "8.33.0", - "debug": "^4.3.4", - "fast-glob": "^3.3.2", - "is-glob": "^4.0.3", - "minimatch": "^9.0.4", - "semver": "^7.6.0", - "ts-api-utils": "^2.1.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.0.tgz", + "integrity": "sha512-HfO0PUCCRte2pMJmVyxPI+eqT7KuV3Fnvn2RPvMe5mOzb2BJKf4/Vth8sSt9cerQboMaTVpbxyYjjLBWIuI5BQ==", + "cpu": [ + "s390x" + ], "dev": true, "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.0.tgz", + "integrity": "sha512-9PZdjP7tLOEjpXHS6+B/RNqtfVUyDEmaViPOuSqcbomLdkJnalt5RKQ1tr2m16+qAufV0aDkfhXtoO7DQos/jg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.0.tgz", + "integrity": "sha512-qkE99ieiSKMnFJY/EfyGKVtNra52/k+lVF/PbO4EL5nU6AdvG4XhtJ+WHojAJP7ID9BNIra/yd75EHndewNRfA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] }, - "node_modules/@typescript-eslint/utils": { - "version": "8.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.33.0.tgz", - "integrity": "sha512-lPFuQaLA9aSNa7D5u2EpRiqdAUhzShwGg/nhpBlc4GR6kcTABttCuyjFs8BcEZ8VWrjCBof/bePhP3Q3fS+Yrw==", + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.0.tgz", + "integrity": "sha512-MjXek8UL9tIX34gymvQLecz2hMaQzOlaqYJJBomwm1gsvK2F7hF+YqJJ2tRyBDTv9EZJGMt4KlKkSD/gZWCOiw==", + "cpu": [ + "wasm32" + ], "dev": true, "license": "MIT", + "optional": true, "dependencies": { - "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.33.0", - "@typescript-eslint/types": "8.33.0", - "@typescript-eslint/typescript-estree": "8.33.0" + "@napi-rs/wasm-runtime": "^0.2.11" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "node": ">=14.0.0" } }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.33.0.tgz", - "integrity": "sha512-7RW7CMYoskiz5OOGAWjJFxgb7c5UNjTG292gYhWeOAcFmYCtVCSqjqSBj5zMhxbXo2JOW95YYrUWJfU0zrpaGQ==", + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.0.tgz", + "integrity": "sha512-9LT6zIGO7CHybiQSh7DnQGwFMZvVr0kUjah6qQfkH2ghucxPV6e71sUXJdSM4Ba0MaGE6DC/NwWf7mJmc3DAng==", + "cpu": [ + "arm64" + ], "dev": true, "license": "MIT", - "dependencies": { - "@typescript-eslint/types": "8.33.0", - "eslint-visitor-keys": "^4.2.0" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } + "optional": true, + "os": [ + "win32" + ] }, - "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.0.tgz", + "integrity": "sha512-HYchBYOZ7WN266VjoGm20xFv5EonG/ODURRgwl9EZT7Bq1nLEs6VKJddzfFdXEAho0wfFlt8L/xIiE29Pmy1RA==", + "cpu": [ + "ia32" + ], "dev": true, - "license": "Apache-2.0", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.0.tgz", + "integrity": "sha512-+oLKLHw3I1UQo4MeHfoLYF+e6YBa8p5vYUw3Rgt7IDzCs+57vIZqQlIo62NDpYM0VG6BjWOwnzBczMvbtH8hag==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", "bin": { @@ -2768,19 +2803,14 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/acorn-walk": { - "version": "8.3.3", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.3.tgz", - "integrity": "sha512-MxXdReSRhGO7VlFe1bRG/oI7/mdLV9B9JJT0N8vZOhF7gFRR5l3M8W9G8JxmKV+JC5mGqJ0QvqfSOLsCPa4nUw==", + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "acorn": "^8.11.0" - }, "engines": { - "node": ">=0.4.0" + "node": ">= 14" } }, "node_modules/ajv": { @@ -2816,19 +2846,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ansi-escapes/node_modules/type-fest": { - "version": "0.21.3", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", - "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2876,140 +2893,129 @@ "node": ">= 8" } }, - "node_modules/arg": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", - "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "dependencies": { + "sprintf-js": "~1.0.2" + } }, - "node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, - "license": "Python-2.0" + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } }, "node_modules/async": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", - "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==", + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", "dev": true, "license": "MIT" }, "node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.0.4.tgz", + "integrity": "sha512-UjG2j7sAOqsp2Xua1mS/e+ekddkSu3wpf4nZUSvXNHuVWdaOUXQ77+uyjJLDE9i0atm5x4kds8K9yb5lRsRtcA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", + "@jest/transform": "30.0.4", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.0", + "babel-preset-jest": "30.0.1", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", "slash": "^3.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.8.0" + "@babel/core": "^7.11.0" } }, "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.0.tgz", + "integrity": "sha512-C5OzENSx/A+gt7t4VH1I2XsflxyPUmXRFPKBxt33xncdOmq7oROVM3bZv9Ysjjkv8OJYDMa+tKuKMvqU/H3xdw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", "test-exclude": "^6.0.0" }, "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" + "node": ">=12" } }, "node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.0.1.tgz", + "integrity": "sha512-zTPME3pI50NsFW8ZBaVIOeAxzEY7XHlmWeXXu9srI+9kNfzCUTy8MFan46xOGZY8NZThMqq+e3qZUKsvXbasnQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" + "@babel/template": "^7.27.2", + "@babel/types": "^7.27.3", + "@types/babel__core": "^7.20.5" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/babel-preset-current-node-syntax": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", - "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.1.0.tgz", + "integrity": "sha512-ldYss8SbBlWva1bs28q78Ju5Zq1F+8BrqBZZ0VFhLBvhh6lCpC2o3gDJi/5DRLs9FgYZCnmPYIVFU4lRXCkyUw==", "dev": true, "license": "MIT", "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.8.3", - "@babel/plugin-syntax-import-meta": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-top-level-await": "^7.8.3" + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "node_modules/babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.0.1.tgz", + "integrity": "sha512-+YHejD5iTWI46cZmcc/YtX4gaKBtdqCHCVfuVinizVpbmyjO3zYmeuyFdfA8duRqQZfgCAMlsfmkVbJ+e2MAJw==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" + "babel-plugin-jest-hoist": "30.0.1", + "babel-preset-current-node-syntax": "^1.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.11.0" } }, "node_modules/balanced-match": { @@ -3019,10 +3025,21 @@ "dev": true, "license": "MIT" }, + "node_modules/benchmark": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/benchmark/-/benchmark-2.1.4.tgz", + "integrity": "sha512-l9MlfN4M1K/H2fbhfMy3B7vJd6AGKJVQn2h6Sg/Yx+KckoUA7ewS5Vv6TjSq18ooE1kS9hhAlQRH3AkXIh/aOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.4", + "platform": "^1.3.3" + } + }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -3044,9 +3061,9 @@ } }, "node_modules/browserslist": { - "version": "4.23.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.1.tgz", - "integrity": "sha512-TUfofFo/KsK/bWZ9TWQ5O26tsWW4Uhmt8IYklbnUa70udB6P2wA7w7o4PY4muaEPBQaAX+CEnmmIA41NVHtPVw==", + "version": "4.25.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.0.tgz", + "integrity": "sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==", "dev": true, "funding": [ { @@ -3064,10 +3081,10 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001629", - "electron-to-chromium": "^1.4.796", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.16" + "caniuse-lite": "^1.0.30001718", + "electron-to-chromium": "^1.5.160", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" @@ -3163,9 +3180,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001640", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001640.tgz", - "integrity": "sha512-lA4VMpW0PSUrFnkmVuEKBUovSWKhj7puyCg8StBChgu298N1AtuF1sKWEvfDuimSEDbhlb/KqPKC3fs1HbuQUA==", + "version": "1.0.30001721", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001721.tgz", + "integrity": "sha512-cOuvmUVtKrtEaoKiO0rSc29jcjwMwX5tOHDy4MgVFEWiUXj4uBMJkwI8MDySkgXidpMiHUcviogAvFi4pA2hDQ==", "dev": true, "funding": [ { @@ -3227,9 +3244,9 @@ } }, "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.2.0.tgz", + "integrity": "sha512-cYY9mypksY8NRqgDB1XD1RiJL338v/551niynFTGkZOO2LHuB2OmOYxDIe/ttN9AHwrqdum1360G3ald0W9kCg==", "dev": true, "funding": [ { @@ -3243,9 +3260,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.3.1.tgz", - "integrity": "sha512-a3KdPAANPbNE4ZUv9h6LckSl9zLsYOP4MBmhIPkRaeyybt+r4UghLvq+xw/YwUcC1gqylCkL4rdVs3Lwupjm4Q==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.0.tgz", + "integrity": "sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA==", "dev": true, "license": "MIT" }, @@ -3254,14 +3271,67 @@ "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, - "license": "ISC", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", "dependencies": { - "string-width": "^4.2.0", - "strip-ansi": "^6.0.1", - "wrap-ansi": "^7.0.0" + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=12" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/co": { @@ -3343,56 +3413,67 @@ "dev": true, "license": "MIT" }, - "node_modules/create-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", - "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "prompts": "^2.0.1" - }, - "bin": { - "create-jest": "bin/create-jest.js" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": ">= 8" } }, - "node_modules/create-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", - "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", - "optional": true, - "peer": true + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", "dev": true, "license": "MIT", "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" }, "engines": { - "node": ">= 8" + "node": ">=18" } }, "node_modules/debug": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", - "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3407,10 +3488,17 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/dedent": { - "version": "1.5.3", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", - "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.6.0.tgz", + "integrity": "sha512-F1Z+5UCFpmQUzJa11agbyPVMbpgT/qA3/SKyJ1jyBgm7dUcUEa8v9JwDkerSQXfakBwFljIxhOJqGkjUwZ9FSA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3459,27 +3547,13 @@ "node": ">=8" } }, - "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", - "dev": true, - "license": "BSD-3-Clause", - "optional": true, - "peer": true, - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, "license": "MIT", - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } + "peer": true }, "node_modules/eastasianwidth": { "version": "0.2.0", @@ -3505,9 +3579,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.816", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.816.tgz", - "integrity": "sha512-EKH5X5oqC6hLmiS7/vYtZHZFTNdhsYG5NVPRN6Yn0kQHNBlT59+xSM8HBy66P5fxWpKgZbPqb+diC64ng295Jw==", + "version": "1.5.166", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.166.tgz", + "integrity": "sha512-QPWqHL0BglzPYyJJ1zSSmwFFL6MFXhbACOCcsCdUMCkzPdS9/OIBVxg516X/Ado2qwAq8k0nJJ7phQPCqiaFAw==", "dev": true, "license": "ISC" }, @@ -3525,12 +3599,25 @@ } }, "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "dev": true, "license": "MIT" }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -3542,9 +3629,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.2", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.2.tgz", - "integrity": "sha512-16854zccKPnC+toMywC+uKNeYSv+/eXkevRAfwRD/G9Cleq66m8XFIrigkbvauLLlCfDL45Q2cWegSg53gGBnQ==", + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3555,37 +3642,37 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.2", - "@esbuild/android-arm": "0.25.2", - "@esbuild/android-arm64": "0.25.2", - "@esbuild/android-x64": "0.25.2", - "@esbuild/darwin-arm64": "0.25.2", - "@esbuild/darwin-x64": "0.25.2", - "@esbuild/freebsd-arm64": "0.25.2", - "@esbuild/freebsd-x64": "0.25.2", - "@esbuild/linux-arm": "0.25.2", - "@esbuild/linux-arm64": "0.25.2", - "@esbuild/linux-ia32": "0.25.2", - "@esbuild/linux-loong64": "0.25.2", - "@esbuild/linux-mips64el": "0.25.2", - "@esbuild/linux-ppc64": "0.25.2", - "@esbuild/linux-riscv64": "0.25.2", - "@esbuild/linux-s390x": "0.25.2", - "@esbuild/linux-x64": "0.25.2", - "@esbuild/netbsd-arm64": "0.25.2", - "@esbuild/netbsd-x64": "0.25.2", - "@esbuild/openbsd-arm64": "0.25.2", - "@esbuild/openbsd-x64": "0.25.2", - "@esbuild/sunos-x64": "0.25.2", - "@esbuild/win32-arm64": "0.25.2", - "@esbuild/win32-ia32": "0.25.2", - "@esbuild/win32-x64": "0.25.2" + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" } }, "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "license": "MIT", "engines": { @@ -3606,19 +3693,19 @@ } }, "node_modules/eslint": { - "version": "9.28.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.28.0.tgz", - "integrity": "sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ==", + "version": "9.30.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.1.tgz", + "integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.28.0", + "@eslint/js": "9.30.1", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -3630,9 +3717,9 @@ "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -3683,9 +3770,9 @@ } }, "node_modules/eslint-plugin-prettier": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.4.1.tgz", - "integrity": "sha512-9dF+KuU/Ilkq27A8idRP7N2DH8iUR6qXcjF3FR2wETY21PZdBrIjwCau8oboyGj9b7etWmTGEeM8e7oOed6ZWg==", + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.1.tgz", + "integrity": "sha512-dobTkHT6XaEVOo8IO90Q4DOSxnm3Y151QxPJlM/vKC0bVy+d6cVWQZLlFiuZPP0wS6vZwSKeJgKkcS+KfMBlRw==", "dev": true, "license": "MIT", "dependencies": { @@ -3714,9 +3801,9 @@ } }, "node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -3731,55 +3818,78 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, "funding": { "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=10" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, - "license": "BSD-2-Clause", + "license": "MIT", "dependencies": { - "acorn": "^8.14.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "p-locate": "^5.0.0" }, "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": ">=10" }, "funding": { - "url": "https://opencollective.com/eslint" + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/espree/node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -3802,9 +3912,9 @@ } }, "node_modules/esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -3871,30 +3981,39 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8.0" } }, "node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.0.4.tgz", + "integrity": "sha512-dDLGjnP2cKbEppxVICxI/Uf4YemmGMPNy0QytCbfafbpYk9AFQsxb8Uyrxii0RPK7FWgLGlSem+07WirwS3cFQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" + "@jest/expect-utils": "30.0.4", + "@jest/get-type": "30.0.1", + "jest-matcher-utils": "30.0.4", + "jest-message-util": "30.0.2", + "jest-mock": "30.0.2", + "jest-util": "30.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/fast-deep-equal": { @@ -3976,9 +4095,9 @@ } }, "node_modules/fetch-mock": { - "version": "12.5.2", - "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-12.5.2.tgz", - "integrity": "sha512-b5KGDFmdmado2MPQjZl6ix3dAG3iwCitb0XQwN72y2s9VnWZ3ObaGNy+bkpm1390foiLDybdJ7yjRGKD36kATw==", + "version": "12.5.3", + "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-12.5.3.tgz", + "integrity": "sha512-SiqPv1IXvDjNjLWCvfFUltba3VeiYucxjyynoVW8Ft07GLFQRitlzjYZI/f5wZpeQFRIVZ84fmMgJfjwb/dAEA==", "dev": true, "license": "MIT", "dependencies": { @@ -4015,9 +4134,9 @@ } }, "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4051,20 +4170,17 @@ } }, "node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "dev": true, "license": "MIT", "dependencies": { - "locate-path": "^6.0.0", + "locate-path": "^5.0.0", "path-exists": "^4.0.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, "node_modules/fix-dts-default-cjs-exports": { @@ -4094,20 +4210,20 @@ } }, "node_modules/flatted": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", - "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", "dev": true, "license": "ISC" }, "node_modules/foreground-child": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz", - "integrity": "sha512-PXUUyLqrR2XCWICfv6ukppP96sdFwWbNEnfEMt7jNsISjMsvaLNinAHNDYyvkyU+SZG2BTSbT5NjG+vZslfGTA==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", "dev": true, "license": "ISC", "dependencies": { - "cross-spawn": "^7.0.0", + "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" }, "engines": { @@ -4117,19 +4233,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -4152,16 +4255,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -4206,22 +4299,21 @@ } }, "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Glob versions prior to v9 are no longer supported", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "dev": true, "license": "ISC", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, - "engines": { - "node": "*" + "bin": { + "glob": "dist/esm/bin.mjs" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -4247,10 +4339,36 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.3.0.tgz", + "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==", "dev": true, "license": "MIT", "engines": { @@ -4284,17 +4402,17 @@ "node": ">=8" } }, - "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", "dev": true, "license": "MIT", "dependencies": { - "function-bind": "^1.1.2" + "whatwg-encoding": "^3.1.1" }, "engines": { - "node": ">= 0.4" + "node": ">=18" } }, "node_modules/html-escaper": { @@ -4304,6 +4422,34 @@ "dev": true, "license": "MIT" }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -4314,10 +4460,23 @@ "node": ">=10.17.0" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "license": "MIT", "engines": { @@ -4341,10 +4500,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/import-fresh/node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/import-local": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", - "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", "dev": true, "license": "MIT", "dependencies": { @@ -4371,6 +4540,16 @@ "node": ">=0.8.19" } }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -4397,22 +4576,6 @@ "dev": true, "license": "MIT" }, - "node_modules/is-core-module": { - "version": "2.14.0", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.14.0.tgz", - "integrity": "sha512-a5dFJih5ZLYlRtDc0dZWP7RiKr6xIKzmn/oAYCDvdLThadVgyJwlaoQPmRtMSpz+rk0OGAgIu+TcM9HUF0fk1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -4466,6 +4629,13 @@ "node": ">=0.12.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -4514,9 +4684,9 @@ } }, "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, "license": "ISC", "bin": { @@ -4542,15 +4712,15 @@ } }, "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", "dev": true, "license": "BSD-3-Clause", "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" + "istanbul-lib-coverage": "^3.0.0" }, "engines": { "node": ">=10" @@ -4571,17 +4741,14 @@ } }, "node_modules/jackspeak": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.0.tgz", - "integrity": "sha512-JVYhQnN59LVPFCEcVa2C3CrEKYacvjRfqIQl+h8oi91aLYQVWRYbxjPcv1bUiUy/kLmQaANrYfNMCO3kuEDHfw==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" }, - "engines": { - "node": ">=14" - }, "funding": { "url": "https://github.com/sponsors/isaacs" }, @@ -4609,22 +4776,22 @@ } }, "node_modules/jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", - "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.0.4.tgz", + "integrity": "sha512-9QE0RS4WwTj/TtTC4h/eFVmFAhGNVerSB9XpJh8sqaXlP73ILcPcZ7JWjjEtJJe2m8QyBLKKfPQuK+3F+Xij/g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "^29.7.0", - "@jest/types": "^29.6.3", - "import-local": "^3.0.2", - "jest-cli": "^29.7.0" + "@jest/core": "30.0.4", + "@jest/types": "30.0.1", + "import-local": "^3.2.0", + "jest-cli": "30.0.4" }, "bin": { "jest": "bin/jest.js" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" @@ -4636,76 +4803,110 @@ } }, "node_modules/jest-changed-files": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", - "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.0.2.tgz", + "integrity": "sha512-Ius/iRST9FKfJI+I+kpiDh8JuUlAISnRszF9ixZDIqJF17FckH5sOzKC8a0wd0+D+8em5ADRHA5V5MnfeDk2WA==", "dev": true, "license": "MIT", "dependencies": { - "execa": "^5.0.0", - "jest-util": "^29.7.0", + "execa": "^5.1.1", + "jest-util": "30.0.2", "p-limit": "^3.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-circus": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", - "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.0.4.tgz", + "integrity": "sha512-o6UNVfbXbmzjYgmVPtSQrr5xFZCtkDZGdTlptYvGFSN80RuOOlTe73djvMrs+QAuSERZWcHBNIOMH+OEqvjWuw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/environment": "30.0.4", + "@jest/expect": "30.0.4", + "@jest/test-result": "30.0.4", + "@jest/types": "30.0.1", "@types/node": "*", - "chalk": "^4.0.0", + "chalk": "^4.1.2", "co": "^4.6.0", - "dedent": "^1.0.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.7.0", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.0.2", + "jest-matcher-utils": "30.0.4", + "jest-message-util": "30.0.2", + "jest-runtime": "30.0.4", + "jest-snapshot": "30.0.4", + "jest-util": "30.0.2", "p-limit": "^3.1.0", - "pretty-format": "^29.7.0", - "pure-rand": "^6.0.0", + "pretty-format": "30.0.2", + "pure-rand": "^7.0.0", "slash": "^3.0.0", - "stack-utils": "^2.0.3" + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-circus/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-circus/node_modules/pretty-format": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", + "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.1", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-circus/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/jest-cli": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", - "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.0.4.tgz", + "integrity": "sha512-3dOrP3zqCWBkjoVG1zjYJpD9143N9GUCbwaF2pFF5brnIgRLHmKcCIw+83BvF1LxggfMWBA0gxkn6RuQVuRhIQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "create-jest": "^29.7.0", - "exit": "^0.1.2", - "import-local": "^3.0.2", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "yargs": "^17.3.1" + "@jest/core": "30.0.4", + "@jest/test-result": "30.0.4", + "@jest/types": "30.0.1", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.0.4", + "jest-util": "30.0.2", + "jest-validate": "30.0.2", + "yargs": "^17.7.2" }, "bin": { "jest": "bin/jest.js" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" @@ -4717,215 +4918,446 @@ } }, "node_modules/jest-config": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", - "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.0.4.tgz", + "integrity": "sha512-3dzbO6sh34thAGEjJIW0fgT0GA0EVlkski6ZzMcbW6dzhenylXAE/Mj2MI4HonroWbkKc6wU6bLVQ8dvBSZ9lA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-jest": "^29.7.0", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "micromatch": "^4.0.4", + "@babel/core": "^7.27.4", + "@jest/get-type": "30.0.1", + "@jest/pattern": "30.0.1", + "@jest/test-sequencer": "30.0.4", + "@jest/types": "30.0.1", + "babel-jest": "30.0.4", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-circus": "30.0.4", + "jest-docblock": "30.0.1", + "jest-environment-node": "30.0.4", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.0.2", + "jest-runner": "30.0.4", + "jest-util": "30.0.2", + "jest-validate": "30.0.2", + "micromatch": "^4.0.8", "parse-json": "^5.2.0", - "pretty-format": "^29.7.0", + "pretty-format": "30.0.2", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { "@types/node": "*", + "esbuild-register": ">=3.4.0", "ts-node": ">=9.0.0" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "esbuild-register": { + "optional": true + }, "ts-node": { "optional": true } } }, + "node_modules/jest-config/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-config/node_modules/pretty-format": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", + "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.1", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-config/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.0.4.tgz", + "integrity": "sha512-TSjceIf6797jyd+R64NXqicttROD+Qf98fex7CowmlSn7f8+En0da1Dglwr1AXxDtVizoxXYZBlUQwNhoOXkNw==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.0.1", + "chalk": "^4.1.2", + "pretty-format": "30.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-diff/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-diff/node_modules/pretty-format": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", + "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.1", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-diff/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/jest-docblock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.0.1.tgz", + "integrity": "sha512-/vF78qn3DYphAaIc3jy4gA7XSAz167n9Bm/wn/1XhTLW7tTBIzXtCJpb/vcmc73NIIeeohCbdL94JasyXUZsGA==", "dev": true, "license": "MIT", "dependencies": { - "detect-newline": "^3.0.0" + "detect-newline": "^3.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-each": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.0.2.tgz", + "integrity": "sha512-ZFRsTpe5FUWFQ9cWTMguCaiA6kkW5whccPy9JjD1ezxh+mJeqmz8naL8Fl/oSbNJv3rgB0x87WBIkA5CObIUZQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" + "@jest/get-type": "30.0.1", + "@jest/types": "30.0.1", + "chalk": "^4.1.2", + "jest-util": "30.0.2", + "pretty-format": "30.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, - "node_modules/jest-environment-node": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "node_modules/jest-each/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-each/node_modules/pretty-format": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", + "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.1", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-each/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-environment-jsdom": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-30.0.4.tgz", + "integrity": "sha512-9WmS3oyCLFgs6DUJSoMpVb+AbH62Y2Xecw3XClbRgj6/Z+VjNeSLjrhBgVvTZ40njZTWeDHv8unp+6M/z8ADDg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/environment": "30.0.4", + "@jest/environment-jsdom-abstract": "30.0.4", + "@types/jsdom": "^21.1.7", "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "jsdom": "^26.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } } }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", + "node_modules/jest-environment-node": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.0.4.tgz", + "integrity": "sha512-p+rLEzC2eThXqiNh9GHHTC0OW5Ca4ZfcURp7scPjYBcmgpR9HG6750716GuUipYf2AcThU3k20B31USuiaaIEg==", "dev": true, "license": "MIT", + "dependencies": { + "@jest/environment": "30.0.4", + "@jest/fake-timers": "30.0.4", + "@jest/types": "30.0.1", + "@types/node": "*", + "jest-mock": "30.0.2", + "jest-util": "30.0.2", + "jest-validate": "30.0.2" + }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.0.2.tgz", + "integrity": "sha512-telJBKpNLeCb4MaX+I5k496556Y2FiKR/QLZc0+MGBYl4k3OO0472drlV2LUe7c1Glng5HuAu+5GLYp//GpdOQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", + "@jest/types": "30.0.1", "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.0.2", + "jest-worker": "30.0.2", + "micromatch": "^4.0.8", "walker": "^1.0.8" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "optionalDependencies": { - "fsevents": "^2.3.2" + "fsevents": "^2.3.3" } }, "node_modules/jest-leak-detector": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", - "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.0.2.tgz", + "integrity": "sha512-U66sRrAYdALq+2qtKffBLDWsQ/XoNNs2Lcr83sc9lvE/hEpNafJlq2lXCPUBMNqamMECNxSIekLfe69qg4KMIQ==", "dev": true, "license": "MIT", "dependencies": { - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "@jest/get-type": "30.0.1", + "pretty-format": "30.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-leak-detector/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-leak-detector/node_modules/pretty-format": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", + "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.1", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-leak-detector/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.0.4.tgz", + "integrity": "sha512-ubCewJ54YzeAZ2JeHHGVoU+eDIpQFsfPQs0xURPWoNiO42LGJ+QGgfSf+hFIRplkZDkhH5MOvuxHKXRTUU3dUQ==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "@jest/get-type": "30.0.1", + "chalk": "^4.1.2", + "jest-diff": "30.0.4", + "pretty-format": "30.0.2" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-matcher-utils/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-matcher-utils/node_modules/pretty-format": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", + "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.1", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-matcher-utils/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.0.2.tgz", + "integrity": "sha512-vXywcxmr0SsKXF/bAD7t7nMamRvPuJkras00gqYeB1V0WllxZrbZ0paRr3XqpFU2sYYjD0qAaG2fRyn/CGZ0aw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.0.1", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.0.2", "slash": "^3.0.0", - "stack-utils": "^2.0.3" + "stack-utils": "^2.0.6" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-message-util/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-message-util/node_modules/pretty-format": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", + "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.1", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-message-util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.0.2.tgz", + "integrity": "sha512-PnZOHmqup/9cT/y+pXIVbbi8ID6U1XHRmbvR7MvUy4SLqhCbwpkmXhLbsWbGewHrV5x/1bF7YDjs+x24/QSvFA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", + "@jest/types": "30.0.1", "@types/node": "*", - "jest-util": "^29.7.0" + "jest-util": "30.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-pnp-resolver": { @@ -4947,153 +5379,189 @@ } }, "node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", "dev": true, "license": "MIT", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-resolve": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", - "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.0.2.tgz", + "integrity": "sha512-q/XT0XQvRemykZsvRopbG6FQUT6/ra+XV6rPijyjT6D0msOyCvR2A5PlWZLd+fH0U8XWKZfDiAgrUNDNX2BkCw==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "resolve": "^1.20.0", - "resolve.exports": "^2.0.0", - "slash": "^3.0.0" + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.0.2", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.0.2", + "jest-validate": "30.0.2", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-resolve-dependencies": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", - "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.0.4.tgz", + "integrity": "sha512-EQBYow19B/hKr4gUTn+l8Z+YLlP2X0IoPyp0UydOtrcPbIOYzJ8LKdFd+yrbwztPQvmlBFUwGPPEzHH1bAvFAw==", "dev": true, "license": "MIT", "dependencies": { - "jest-regex-util": "^29.6.3", - "jest-snapshot": "^29.7.0" + "jest-regex-util": "30.0.1", + "jest-snapshot": "30.0.4" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runner": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", - "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.0.4.tgz", + "integrity": "sha512-mxY0vTAEsowJwvFJo5pVivbCpuu6dgdXRmt3v3MXjBxFly7/lTk3Td0PaMyGOeNQUFmSuGEsGYqhbn7PA9OekQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "^29.7.0", - "@jest/environment": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/console": "30.0.4", + "@jest/environment": "30.0.4", + "@jest/test-result": "30.0.4", + "@jest/transform": "30.0.4", + "@jest/types": "30.0.1", "@types/node": "*", - "chalk": "^4.0.0", + "chalk": "^4.1.2", "emittery": "^0.13.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-leak-detector": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-resolve": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-util": "^29.7.0", - "jest-watcher": "^29.7.0", - "jest-worker": "^29.7.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.0.1", + "jest-environment-node": "30.0.4", + "jest-haste-map": "30.0.2", + "jest-leak-detector": "30.0.2", + "jest-message-util": "30.0.2", + "jest-resolve": "30.0.2", + "jest-runtime": "30.0.4", + "jest-util": "30.0.2", + "jest-watcher": "30.0.4", + "jest-worker": "30.0.2", "p-limit": "^3.1.0", "source-map-support": "0.5.13" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runtime": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", - "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.0.4.tgz", + "integrity": "sha512-tUQrZ8+IzoZYIHoPDQEB4jZoPyzBjLjq7sk0KVyd5UPRjRDOsN7o6UlvaGF8ddpGsjznl9PW+KRgWqCNO+Hn7w==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/globals": "^29.7.0", - "@jest/source-map": "^29.6.3", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/environment": "30.0.4", + "@jest/fake-timers": "30.0.4", + "@jest/globals": "30.0.4", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.0.4", + "@jest/transform": "30.0.4", + "@jest/types": "30.0.1", "@types/node": "*", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.0.2", + "jest-message-util": "30.0.2", + "jest-mock": "30.0.2", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.0.2", + "jest-snapshot": "30.0.4", + "jest-util": "30.0.2", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot": { + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.0.4.tgz", + "integrity": "sha512-S/8hmSkeUib8WRUq9pWEb5zMfsOjiYWDWzFzKnjX7eDyKKgimsu9hcmsUEg8a7dPAw8s/FacxsXquq71pDgPjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.0.4", + "@jest/get-type": "30.0.1", + "@jest/snapshot-utils": "30.0.4", + "@jest/transform": "30.0.4", + "@jest/types": "30.0.1", + "babel-preset-current-node-syntax": "^1.1.0", + "chalk": "^4.1.2", + "expect": "30.0.4", + "graceful-fs": "^4.2.11", + "jest-diff": "30.0.4", + "jest-matcher-utils": "30.0.4", + "jest-message-util": "30.0.2", + "jest-util": "30.0.2", + "pretty-format": "30.0.2", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-snapshot/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/jest-snapshot": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", - "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", + "node_modules/jest-snapshot/node_modules/pretty-format": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", + "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "natural-compare": "^1.4.0", - "pretty-format": "^29.7.0", - "semver": "^7.5.3" + "@jest/schemas": "30.0.1", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-snapshot/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, "license": "ISC", "bin": { @@ -5104,39 +5572,65 @@ } }, "node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.0.2.tgz", + "integrity": "sha512-8IyqfKS4MqprBuUpZNlFB5l+WFehc8bfCe1HSZFHzft2mOuND8Cvi9r1musli+u6F3TqanCZ/Ik4H4pXUolZIg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", + "@jest/types": "30.0.1", "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", + "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.0.2.tgz", + "integrity": "sha512-noOvul+SFER4RIvNAwGn6nmV2fXqBq67j+hKGHKGFCmK4ks/Iy1FSrqQNBLGKlu4ZZIRL6Kg1U72N1nxuRCrGQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", + "@jest/get-type": "30.0.1", + "@jest/types": "30.0.1", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "^29.7.0" + "pretty-format": "30.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, "node_modules/jest-validate/node_modules/camelcase": { @@ -5152,40 +5646,63 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/jest-validate/node_modules/pretty-format": { + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.0.2.tgz", + "integrity": "sha512-yC5/EBSOrTtqhCKfLHqoUIAXVRZnukHPwWBJWR7h84Q3Be1DRQZLncwcfLoPA5RPQ65qfiCMqgYwdUuQ//eVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/schemas": "30.0.1", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-validate/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" + }, "node_modules/jest-watcher": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", - "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "version": "30.0.4", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.0.4.tgz", + "integrity": "sha512-YESbdHDs7aQOCSSKffG8jXqOKFqw4q4YqR+wHYpR5GWEQioGvL0BfbcjvKIvPEM0XGfsfJrka7jJz3Cc3gI4VQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/test-result": "30.0.4", + "@jest/types": "30.0.1", "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "^29.7.0", - "string-length": "^4.0.1" + "jest-util": "30.0.2", + "string-length": "^4.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "version": "30.0.2", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.0.2.tgz", + "integrity": "sha512-RN1eQmx7qSLFA+o9pfJKlqViwL5wt+OL3Vff/A+/cPsmuw7NPwfgl33AP+/agRmHzPOFgXviRycR9kYwlcRQXg==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", - "jest-util": "^29.7.0", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.0.2", "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" + "supports-color": "^8.1.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-worker/node_modules/supports-color": { @@ -5232,29 +5749,70 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "version": "3.14.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", + "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", "dev": true, "license": "MIT", "dependencies": { - "argparse": "^2.0.1" + "argparse": "^1.0.7", + "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, "license": "MIT", "bin": { "jsesc": "bin/jsesc" }, "engines": { - "node": ">=4" + "node": ">=6" } }, "node_modules/json-buffer": { @@ -5308,16 +5866,6 @@ "json-buffer": "3.0.1" } }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -5373,21 +5921,25 @@ } }, "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", "dev": true, "license": "MIT", "dependencies": { - "p-locate": "^5.0.0" + "p-locate": "^4.1.0" }, "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=8" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -5419,6 +5971,17 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", @@ -5446,9 +6009,9 @@ } }, "node_modules/make-dir/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", "dev": true, "license": "ISC", "bin": { @@ -5516,6 +6079,16 @@ "node": ">=6" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -5600,6 +6173,22 @@ "picocolors": "^1.1.1" } }, + "node_modules/napi-postinstall": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.0.tgz", + "integrity": "sha512-M7NqKyhODKV1gRLdkwE7pDsZP2/SC2a2vHkOYh9MCpKMbWVfyVfUw5MaH83Fv6XMjxr5jryUp3IDDL9rlxsTeA==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -5615,9 +6204,9 @@ "license": "MIT" }, "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", "dev": true, "license": "MIT" }, @@ -5644,6 +6233,13 @@ "node": ">=8" } }, + "node_modules/nwsapi": { + "version": "2.2.20", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.20.tgz", + "integrity": "sha512-/ieB+mDe4MrrKMT8z+mQL8klXydZWGR5Dowt4RAGKbJ3kIGEx3X4ljUo+6V73IXtUPWgfOlU5B9MlGxFO5T+cA==", + "dev": true, + "license": "MIT" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5715,16 +6311,29 @@ } }, "node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", "dev": true, "license": "MIT", "dependencies": { - "p-limit": "^3.0.2" + "p-limit": "^2.2.0" }, "engines": { - "node": ">=10" + "node": ">=8" + } + }, + "node_modules/p-locate/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -5741,9 +6350,9 @@ } }, "node_modules/package-json-from-dist": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", - "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", "dev": true, "license": "BlueOak-1.0.0" }, @@ -5779,6 +6388,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -5809,13 +6431,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", @@ -5834,14 +6449,11 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.3.0.tgz", - "integrity": "sha512-CQl19J/g+Hbjbv4Y3mFNNXFEL/5t/KCg8POCuUqd4rMKjGG+j1ybER83hxV58zL+dFI1PTkt3GNFSHRt+d8qEQ==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", "dev": true, - "license": "ISC", - "engines": { - "node": "14 || >=16.14" - } + "license": "ISC" }, "node_modules/pathe": { "version": "2.0.3", @@ -5871,9 +6483,9 @@ } }, "node_modules/pirates": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", - "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", "dev": true, "license": "MIT", "engines": { @@ -5893,62 +6505,6 @@ "node": ">=8" } }, - "node_modules/pkg-dir/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "license": "MIT", - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-locate": "^4.1.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/pkg-dir/node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-try": "^2.0.0" - }, - "engines": { - "node": ">=6" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pkg-dir/node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", - "dev": true, - "license": "MIT", - "dependencies": { - "p-limit": "^2.2.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/pkg-types": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-1.3.1.tgz", @@ -5961,6 +6517,13 @@ "pathe": "^2.0.1" } }, + "node_modules/platform": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/platform/-/platform-1.3.6.tgz", + "integrity": "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==", + "dev": true, + "license": "MIT" + }, "node_modules/postcss-load-config": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", @@ -6015,9 +6578,9 @@ } }, "node_modules/prettier": { - "version": "3.5.3", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.5.3.tgz", - "integrity": "sha512-QQtaxnoDJeAkDvDKWCLiwIXkTgRhwYDEQCghU9Z6q03iyek/rxRh/2lC3HB7P8sWT2xC/y5JDctPLBIGzHKbhw==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, "license": "MIT", "bin": { @@ -6044,18 +6607,19 @@ } }, "node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@jest/schemas": "^29.6.3", + "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "react-is": "^17.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, "node_modules/pretty-format/node_modules/ansi-styles": { @@ -6064,6 +6628,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -6071,20 +6636,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -6096,9 +6647,9 @@ } }, "node_modules/pure-rand": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", - "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", "dev": true, "funding": [ { @@ -6133,12 +6684,36 @@ ], "license": "MIT" }, + "node_modules/react": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", + "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.1.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", + "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "scheduler": "^0.26.0" + }, + "peerDependencies": { + "react": "^19.1.0" + } + }, "node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/readdirp": { "version": "4.1.2", @@ -6154,6 +6729,20 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/regexparam": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/regexparam/-/regexparam-3.0.0.tgz", @@ -6174,24 +6763,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/resolve-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", @@ -6205,7 +6776,7 @@ "node": ">=8" } }, - "node_modules/resolve-cwd/node_modules/resolve-from": { + "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", @@ -6215,26 +6786,6 @@ "node": ">=8" } }, - "node_modules/resolve-from": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", - "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/resolve.exports": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz", - "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - } - }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -6247,9 +6798,9 @@ } }, "node_modules/rollup": { - "version": "4.38.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.38.0.tgz", - "integrity": "sha512-5SsIRtJy9bf1ErAOiFMFzl64Ex9X5V7bnJ+WlFMb+zmP459OSWCEG7b0ERZ+PEU7xPt4OG3RHbrp1LJlXxYTrw==", + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.42.0.tgz", + "integrity": "sha512-LW+Vse3BJPyGJGAJt1j8pWDKPd73QM8cRXYK1IxOBgL2AGLu7Xd2YOW0M2sLUBCkF5MshXXtMApyEAEzMVMsnw==", "dev": true, "license": "MIT", "dependencies": { @@ -6263,29 +6814,57 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.38.0", - "@rollup/rollup-android-arm64": "4.38.0", - "@rollup/rollup-darwin-arm64": "4.38.0", - "@rollup/rollup-darwin-x64": "4.38.0", - "@rollup/rollup-freebsd-arm64": "4.38.0", - "@rollup/rollup-freebsd-x64": "4.38.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.38.0", - "@rollup/rollup-linux-arm-musleabihf": "4.38.0", - "@rollup/rollup-linux-arm64-gnu": "4.38.0", - "@rollup/rollup-linux-arm64-musl": "4.38.0", - "@rollup/rollup-linux-loongarch64-gnu": "4.38.0", - "@rollup/rollup-linux-powerpc64le-gnu": "4.38.0", - "@rollup/rollup-linux-riscv64-gnu": "4.38.0", - "@rollup/rollup-linux-riscv64-musl": "4.38.0", - "@rollup/rollup-linux-s390x-gnu": "4.38.0", - "@rollup/rollup-linux-x64-gnu": "4.38.0", - "@rollup/rollup-linux-x64-musl": "4.38.0", - "@rollup/rollup-win32-arm64-msvc": "4.38.0", - "@rollup/rollup-win32-ia32-msvc": "4.38.0", - "@rollup/rollup-win32-x64-msvc": "4.38.0", + "@rollup/rollup-android-arm-eabi": "4.42.0", + "@rollup/rollup-android-arm64": "4.42.0", + "@rollup/rollup-darwin-arm64": "4.42.0", + "@rollup/rollup-darwin-x64": "4.42.0", + "@rollup/rollup-freebsd-arm64": "4.42.0", + "@rollup/rollup-freebsd-x64": "4.42.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.42.0", + "@rollup/rollup-linux-arm-musleabihf": "4.42.0", + "@rollup/rollup-linux-arm64-gnu": "4.42.0", + "@rollup/rollup-linux-arm64-musl": "4.42.0", + "@rollup/rollup-linux-loongarch64-gnu": "4.42.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.42.0", + "@rollup/rollup-linux-riscv64-gnu": "4.42.0", + "@rollup/rollup-linux-riscv64-musl": "4.42.0", + "@rollup/rollup-linux-s390x-gnu": "4.42.0", + "@rollup/rollup-linux-x64-gnu": "4.42.0", + "@rollup/rollup-linux-x64-musl": "4.42.0", + "@rollup/rollup-win32-arm64-msvc": "4.42.0", + "@rollup/rollup-win32-ia32-msvc": "4.42.0", + "@rollup/rollup-win32-x64-msvc": "4.42.0", "fsevents": "~2.3.2" } }, + "node_modules/rollup/node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.42.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.42.0.tgz", + "integrity": "sha512-Gfm6cV6mj3hCUY8TqWa63DB8Mx3NADoFwiJrMpoZ1uESbK8FQV3LXkhfry+8bOniq9pqY1OdsjFWNsSbfjPugw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", + "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -6310,6 +6889,33 @@ "queue-microtask": "^1.2.2" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", + "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "dev": true, + "license": "MIT" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -6344,18 +6950,17 @@ } }, "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", "dev": true, - "license": "MIT" + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, "node_modules/size-limit": { "version": "11.2.0", @@ -6454,21 +7059,37 @@ "node": ">=10" } }, - "node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "node_modules/string-length/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", "dev": true, "license": "MIT", "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" + "ansi-regex": "^5.0.1" }, "engines": { "node": ">=8" } }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/string-width-cjs": { "name": "string-width", "version": "4.2.3", @@ -6485,7 +7106,14 @@ "node": ">=8" } }, - "node_modules/strip-ansi": { + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", @@ -6498,6 +7126,22 @@ "node": ">=8" } }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/strip-ansi-cjs": { "name": "strip-ansi", "version": "6.0.1", @@ -6512,6 +7156,19 @@ "node": ">=8" } }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", @@ -6532,6 +7189,19 @@ "node": ">=6" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -6568,56 +7238,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/sucrase/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/sucrase/node_modules/glob": { - "version": "10.4.2", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.2.tgz", - "integrity": "sha512-GwMlUF6PkPo3Gk21UxkCohOv0PLcIXVtKyLlpEI28R/cO/4eNOdmLk3CMW1wROV/WR/EsZOWAfBbBOqYvs88/w==", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/sucrase/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -6631,18 +7251,12 @@ "node": ">=8" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "license": "MIT" }, "node_modules/synckit": { "version": "0.11.8", @@ -6675,6 +7289,28 @@ "node": ">=8" } }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -6706,13 +7342,13 @@ "license": "MIT" }, "node_modules/tinyglobby": { - "version": "0.2.12", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.12.tgz", - "integrity": "sha512-qkf4trmKSIiMTs/E63cxH+ojC2unam7rJ0WrauAzpT3ECNTxGRMlaXxVbfxMUC/w0LaYk6jQ4y/nGR9uBO3tww==", + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", + "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.3", + "fdir": "^6.4.4", "picomatch": "^4.0.2" }, "engines": { @@ -6723,9 +7359,9 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.3", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.3.tgz", - "integrity": "sha512-PMXmW2y1hDDfTSRc9gaXIuCCRpuoz3Kaz8cUelp3smouvfT632ozg2vrT6lJsHKKOF59YLbOGfAWGUcKEfRMQw==", + "version": "6.4.6", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.6.tgz", + "integrity": "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w==", "dev": true, "license": "MIT", "peerDependencies": { @@ -6750,6 +7386,26 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -6767,17 +7423,33 @@ "is-number": "^7.0.0" }, "engines": { - "node": ">=8.0" + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" } }, "node_modules/tr46": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", - "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "dev": true, "license": "MIT", "dependencies": { - "punycode": "^2.1.0" + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" } }, "node_modules/tree-kill": { @@ -6811,16 +7483,15 @@ "license": "Apache-2.0" }, "node_modules/ts-jest": { - "version": "29.3.4", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.3.4.tgz", - "integrity": "sha512-Iqbrm8IXOmV+ggWHOTEbjwyCf2xZlUMv5npExksXohL+tk8va4Fjhb+X2+Rt9NBmgO7bJ8WpnMLOwih/DnMlFA==", + "version": "29.4.0", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.0.tgz", + "integrity": "sha512-d423TJMnJGu80/eSgfQ5w/R+0zFJvdtTxwtF9KzFFunOpSeD+79lHJQIiAhluJoyGRbvj9NZJsl9WjCUo0ND7Q==", "dev": true, "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", "ejs": "^3.1.10", "fast-json-stable-stringify": "^2.1.0", - "jest-util": "^29.0.0", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", @@ -6836,10 +7507,11 @@ }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", - "@jest/transform": "^29.0.0", - "@jest/types": "^29.0.0", - "babel-jest": "^29.0.0", - "jest": "^29.0.0", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", "typescript": ">=4.3 <6" }, "peerDependenciesMeta": { @@ -6857,6 +7529,9 @@ }, "esbuild": { "optional": true + }, + "jest-util": { + "optional": true } } }, @@ -6873,50 +7548,17 @@ "node": ">=10" } }, - "node_modules/ts-node": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", - "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@cspotcode/source-map-support": "^0.8.0", - "@tsconfig/node10": "^1.0.7", - "@tsconfig/node12": "^1.0.7", - "@tsconfig/node14": "^1.0.0", - "@tsconfig/node16": "^1.0.2", - "acorn": "^8.4.1", - "acorn-walk": "^8.1.1", - "arg": "^4.1.0", - "create-require": "^1.1.0", - "diff": "^4.0.1", - "make-error": "^1.1.1", - "v8-compile-cache-lib": "^3.0.1", - "yn": "3.1.1" - }, - "bin": { - "ts-node": "dist/bin.js", - "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-esm": "dist/bin-esm.js", - "ts-node-script": "dist/bin-script.js", - "ts-node-transpile-only": "dist/bin-transpile.js", - "ts-script": "dist/bin-script-deprecated.js" - }, - "peerDependencies": { - "@swc/core": ">=1.2.50", - "@swc/wasm": ">=1.2.50", - "@types/node": "*", - "typescript": ">=2.7" + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" }, - "peerDependenciesMeta": { - "@swc/core": { - "optional": true - }, - "@swc/wasm": { - "optional": true - } + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/tslib": { @@ -6979,16 +7621,6 @@ } } }, - "node_modules/tsup/node_modules/resolve-from": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", - "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, "node_modules/tsup/node_modules/source-map": { "version": "0.8.0-beta.0", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.8.0-beta.0.tgz", @@ -7002,6 +7634,35 @@ "node": ">= 8" } }, + "node_modules/tsup/node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/tsup/node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/tsup/node_modules/whatwg-url": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", + "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -7026,13 +7687,13 @@ } }, "node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "dev": true, "license": "(MIT OR CC0-1.0)", "engines": { - "node": ">=16" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -7053,15 +7714,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.33.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.33.0.tgz", - "integrity": "sha512-5YmNhF24ylCsvdNW2oJwMzTbaeO4bg90KeGtMjUw0AGtHksgEPLRTUil+coHwCfiu4QjVJFnjp94DmU6zV7DhQ==", + "version": "8.36.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.36.0.tgz", + "integrity": "sha512-fTCqxthY+h9QbEgSIBfL9iV6CvKDFuoxg6bHPNpJ9HIUzS+jy2lCEyCmGyZRWEBSaykqcDPf1SJ+BfCI8DRopA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.33.0", - "@typescript-eslint/parser": "8.33.0", - "@typescript-eslint/utils": "8.33.0" + "@typescript-eslint/eslint-plugin": "8.36.0", + "@typescript-eslint/parser": "8.36.0", + "@typescript-eslint/utils": "8.36.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -7083,16 +7744,51 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", + "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", "dev": true, "license": "MIT" }, + "node_modules/unrs-resolver": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.0.tgz", + "integrity": "sha512-uw3hCGO/RdAEAb4zgJ3C/v6KIAFFOtBoxR86b2Ejc5TnH7HrhTWJR2o0A9ullC3eWMegKQCw/arQ/JivywQzkg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.0", + "@unrs/resolver-binding-android-arm64": "1.11.0", + "@unrs/resolver-binding-darwin-arm64": "1.11.0", + "@unrs/resolver-binding-darwin-x64": "1.11.0", + "@unrs/resolver-binding-freebsd-x64": "1.11.0", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.0", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.0", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.0", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.0", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.0", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.0", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.0", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.0", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.0", + "@unrs/resolver-binding-linux-x64-musl": "1.11.0", + "@unrs/resolver-binding-wasm32-wasi": "1.11.0", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.0", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.0", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.0" + } + }, "node_modules/update-browserslist-db": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", - "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", "dev": true, "funding": [ { @@ -7110,8 +7806,8 @@ ], "license": "MIT", "dependencies": { - "escalade": "^3.1.2", - "picocolors": "^1.0.1" + "escalade": "^3.2.0", + "picocolors": "^1.1.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -7130,15 +7826,6 @@ "punycode": "^2.1.0" } }, - "node_modules/v8-compile-cache-lib": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", - "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true - }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -7154,6 +7841,19 @@ "node": ">=10.12.0" } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/walker": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/walker/-/walker-1.0.8.tgz", @@ -7165,22 +7865,50 @@ } }, "node_modules/webidl-conversions": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", "dev": true, - "license": "BSD-2-Clause" + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/whatwg-url": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", - "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", "dev": true, "license": "MIT", "dependencies": { - "lodash.sortby": "^4.7.0", - "tr46": "^1.0.1", - "webidl-conversions": "^4.0.2" + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" } }, "node_modules/which": { @@ -7210,18 +7938,18 @@ } }, "node_modules/wrap-ansi": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" }, "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -7246,6 +7974,54 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", @@ -7254,19 +8030,58 @@ "license": "ISC" }, "node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", "dev": true, "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" + "signal-exit": "^4.0.1" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": ">=18" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", @@ -7313,16 +8128,39 @@ "node": ">=12" } }, - "node_modules/yn": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", - "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", "dev": true, "license": "MIT", - "optional": true, - "peer": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, "engines": { - "node": ">=6" + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" } }, "node_modules/yocto-queue": { diff --git a/package.json b/package.json index 2952ef0d..255c79bb 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,22 @@ "module": "dist/browser/index.mjs", "types": "dist/index.d.ts", "unpkg": "./dist/browser/index.mjs", + "exports": { + ".": { + "import": { + "node": "./dist/node/index.js", + "default": "./dist/browser/index.mjs" + }, + "require": { + "node": "./dist/node/index.js", + "default": "./dist/browser/index.global.js" + } + }, + "./react": { + "import": "./dist/react/index.mjs", + "require": "./dist/react/index.cjs" + } + }, "keywords": [ "fetch", "fetchff", @@ -34,9 +50,7 @@ }, "sideEffects": false, "scripts": { - "build": "npm run build:node && npm run build:browser && npm run build:cleanup", - "build:browser": "tsup --format esm,iife --out-dir dist/browser --env.NODE_ENV production", - "build:node": "tsup --format cjs --out-dir dist/node --env.NODE_ENV production --target node18", + "build": "tsup --config tsup.config.ts && npm run build:cleanup", "build:cleanup": "rm -f dist/browser/index.d.mts dist/node/index.d.ts && mv dist/browser/index.d.ts dist/index.d.ts", "type-check": "tsc --noEmit", "test": "jest --forceExit --coverage --detectOpenHandles", @@ -55,32 +69,48 @@ "size-limit": [ { "path": "dist/browser/index.mjs", - "limit": "5 KB" + "limit": "5.5 KB" }, { "path": "dist/browser/index.global.js", - "limit": "5 KB" + "limit": "5.6 KB" }, { "path": "dist/node/index.js", - "limit": "5 KB" + "limit": "5.5 KB" + }, + { + "path": "dist/react/index.mjs", + "limit": "9.5 KB" + }, + { + "path": "dist/react/index.js", + "limit": "9.5 KB" } ], "devDependencies": { "@size-limit/preset-small-lib": "11.2.0", - "@types/jest": "29.5.14", - "eslint": "9.28.0", + "@testing-library/jest-dom": "6.6.3", + "@testing-library/react": "16.3.0", + "@types/jest": "30.0.0", + "@types/react": "19.1.8", + "benchmark": "2.1.4", + "eslint": "9.30.1", "eslint-config-prettier": "10.1.5", - "eslint-plugin-prettier": "5.4.1", - "fetch-mock": "12.5.2", - "jest": "29.7.0", - "prettier": "3.5.3", + "eslint-plugin-prettier": "5.5.1", + "fetch-mock": "12.5.3", + "globals": "16.3.0", + "jest": "30.0.4", + "jest-environment-jsdom": "30.0.4", + "prettier": "3.6.2", + "react": "19.1.0", + "react-dom": "19.1.0", "size-limit": "11.2.0", - "ts-jest": "29.3.4", + "ts-jest": "29.4.0", "tslib": "2.8.1", "tsup": "8.5.0", "typescript": "5.8.3", - "typescript-eslint": "8.33.0" + "typescript-eslint": "8.36.0" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.38.0" diff --git a/src/api-handler.ts b/src/api-handler.ts index 9e932db5..1310e4a2 100644 --- a/src/api-handler.ts +++ b/src/api-handler.ts @@ -1,54 +1,23 @@ -import type { - RequestConfig, - FetchResponse, - DefaultResponse, - CreatedCustomFetcherInstance, -} from './types/request-handler'; import type { ApiHandlerConfig, ApiHandlerDefaultMethods, ApiHandlerMethods, - DefaultPayload, - FallbackValue, - FinalParams, - FinalResponse, - QueryParams, RequestConfigUrlRequired, - UrlPathParams, } from './types/api-handler'; -import { createRequestHandler } from './request-handler'; import { fetchf } from '.'; -import { mergeConfig } from './config-handler'; +import { mergeConfigs } from './config-handler'; +import { isAbsoluteUrl } from './utils'; /** * Creates an instance of API Handler. - * It creates an API fetcher function using native fetch() or a custom fetcher if it is passed as "fetcher". - * @url https://github.com/MattCCC/fetchff + * It creates an API fetcher function using native fetch() or a custom fetcher if passed as "fetcher". + * @see https://github.com/MattCCC/fetchff#configuration * - * @param {Object} config - Configuration object for the API fetcher. - * @param {string} config.apiUrl - The base URL for the API. + * @param {Object} config - Configuration object for the API fetcher (see link above for full options). * @param {Object} config.endpoints - An object containing endpoint definitions. - * @param {number} config.timeout - You can set the timeout for particular request in milliseconds. - * @param {number} config.cancellable - If true, the ongoing previous requests will be automatically cancelled. - * @param {number} config.rejectCancelled - If true and request is set to cancellable, a cancelled request promise will be rejected. By default, instead of rejecting the promise, defaultResponse is returned. - * @param {number} config.timeout - Request timeout - * @param {number} config.dedupeTime - Time window, in milliseconds, during which identical requests are deduplicated (treated as single request). - * @param {string} config.strategy - Error Handling Strategy - * @param {string} config.flattenResponse - Whether to flatten response "data" object within "data". It works only if the response structure includes a single data property. - * @param {*} config.defaultResponse - Default response when there is no data or when endpoint fails depending on the chosen strategy. It's "null" by default - * @param {Object} [config.retry] - Options for retrying requests. - * @param {number} [config.retry.retries=0] - Number of retry attempts. No retries by default. - * @param {number} [config.retry.delay=1000] - Initial delay between retries in milliseconds. - * @param {number} [config.retry.backoff=1.5] - Exponential backoff factor. - * @param {number[]} [config.retry.retryOn=[502, 504, 408]] - HTTP status codes to retry on. - * @param {RequestInterceptor|RequestInterceptor[]} [config.onRequest] - Optional request interceptor function or an array of functions. - * These functions will be called with the request configuration object before the request is made. Can be used to modify or log the request configuration. - * @param {ResponseInterceptor|ResponseInterceptor[]} [config.onResponse] - Optional response interceptor function or an array of functions. - * These functions will be called with the response object after the response is received. an be used to modify or log the response data. - * @param {Function} [config.onError] - Optional callback function for handling errors. + * @param {string} [config.baseURL] - The base URL for the API. * @param {Object} [config.headers] - Optional default headers to include in every request. - * @param {Object} config.fetcher - The Custom Fetcher instance to use for making requests. It should expose create() and request() functions. - * @param {*} config.logger - Instance of custom logger. Either class or an object similar to "console". Console is used by default. + * @param {Function} [config.onError] - Optional callback function for handling errors. * @returns API handler functions and endpoints to call * * @example @@ -74,20 +43,10 @@ import { mergeConfig } from './config-handler'; * const response = await api.getUser({ userId: 1, ratings: [1, 2] }) */ function createApiFetcher< - EndpointsMethods extends object, + EndpointTypes extends object, EndpointsSettings = never, ->(config: ApiHandlerConfig) { +>(config: ApiHandlerConfig) { const endpoints = config.endpoints; - const requestHandler = createRequestHandler(config); - - /** - * Get Custom Fetcher Provider Instance - * - * @returns {CreatedCustomFetcherInstance | null} Request Handler's Custom Fetcher Instance - */ - function getInstance(): CreatedCustomFetcherInstance | null { - return requestHandler.getInstance(); - } /** * Triggered when trying to use non-existent endpoints @@ -101,71 +60,42 @@ function createApiFetcher< return Promise.resolve(null); } - /** - * Handle Single API Request - * It considers settings in following order: per-request settings, global per-endpoint settings, global settings. - * - * @param {keyof EndpointsMethods | string} endpointName - The name of the API endpoint to call. - * @param {EndpointConfig} [requestConfig={}] - Additional configuration for the request. - * @returns {Promise>} - A promise that resolves with the response from the API provider. - */ - async function request< - ResponseData = never, - QueryParams_ = never, - UrlParams = never, - RequestBody = never, - >( - endpointName: keyof EndpointsMethods | string, - requestConfig: RequestConfig< - FinalResponse, - FinalParams, - FinalParams, - FallbackValue - > = {}, - ): Promise>> { - // Use global per-endpoint settings - const endpointConfig = - endpoints[endpointName] || - ({ url: String(endpointName) } as RequestConfigUrlRequired); - const url = endpointConfig.url; - - // Block Protocol-relative URLs as they could lead to SSRF (Server-Side Request Forgery) - if (url.startsWith('//')) { - throw new Error('Protocol-relative URLs are not allowed.'); - } - - // Prevent potential Server-Side Request Forgery attack and leakage of credentials when same instance is used for external requests - const isAbsoluteUrl = url.includes('://'); - - if (isAbsoluteUrl) { - // Retrigger fetch to ensure completely new instance of handler being triggered for external URLs - return await fetchf(url, requestConfig); - } - - const mergedConfig = { - ...endpointConfig, - ...requestConfig, - }; - - mergeConfig('retry', mergedConfig, endpointConfig, requestConfig); - mergeConfig('headers', mergedConfig, endpointConfig, requestConfig); - - const responseData = await requestHandler.request< - FinalResponse, - FinalParams, - FinalParams, - FallbackValue - >(url, mergedConfig); - - return responseData; - } - - const apiHandler: ApiHandlerDefaultMethods = { + const apiHandler: ApiHandlerDefaultMethods = { config, endpoints, - requestHandler, - getInstance, - request, + /** + * Handle Single API Request + * It considers settings in following order: per-request settings, global per-endpoint settings, global settings. + * + * @param endpointName - The name of the API endpoint to call. + * @param requestConfig - Additional configuration for the request. + * @returns A promise that resolves with the response from the API provider. + */ + async request(endpointName, requestConfig = {}) { + // Use global and per-endpoint settings + const endpointConfig = endpoints[endpointName]; + const _endpointConfig = + endpointConfig || + ({ url: String(endpointName) } as RequestConfigUrlRequired); + const url = _endpointConfig.url; + + // Block Protocol-relative URLs as they could lead to SSRF (Server-Side Request Forgery) + if (url.startsWith('//')) { + throw new Error('Protocol-relative URLs are not allowed.'); + } + + // Prevent potential Server-Side Request Forgery attack and leakage of credentials when same instance is used for external requests + const mergedConfig = isAbsoluteUrl(url) + ? // Merge endpoints configs for absolute URLs only if urls match + endpointConfig?.url === url + ? mergeConfigs(_endpointConfig, requestConfig) + : requestConfig + : mergeConfigs(mergeConfigs(config, _endpointConfig), requestConfig); + + // We prevent potential Server-Side Request Forgery attack and leakage of credentials as the same instance is not used for external requests + // Retrigger fetch to ensure completely new instance of handler being triggered for external URLs + return fetchf(url, mergedConfig); + }, }; /** @@ -173,8 +103,8 @@ function createApiFetcher< * * @param {*} prop Caller */ - return new Proxy>( - apiHandler as ApiHandlerMethods, + return new Proxy>( + apiHandler as ApiHandlerMethods, { get(_target, prop: string) { if (prop in apiHandler) { diff --git a/src/cache-manager.ts b/src/cache-manager.ts index 31d68cba..02b0bbdd 100644 --- a/src/cache-manager.ts +++ b/src/cache-manager.ts @@ -1,31 +1,82 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { hash } from './hash'; -import { fetchf } from './index'; -import type { FetcherConfig, FetchResponse } from './types/request-handler'; +import type { + CacheKeyFunction, + DefaultResponse, + FetchResponse, + MutationSettings, + RequestConfig, +} from './types/request-handler'; import type { CacheEntry } from './types/cache-manager'; -import { GET, OBJECT, UNDEFINED } from './constants'; -import { shallowSerialize, sortObject } from './utils'; +import { GET, STRING, UNDEFINED } from './constants'; +import { isObject, sanitizeObject, sortObject, timeNow } from './utils'; +import { revalidate } from './revalidator-manager'; +import { notifySubscribers } from './pubsub-manager'; +import type { DefaultPayload, DefaultParams, DefaultUrlParams } from './types'; +import { removeInFlight } from './inflight-manager'; +import { addTimeout } from './timeout-wheel'; +import { defaultConfig } from './config-handler'; +import { processHeaders } from './utils'; -const cache = new Map>(); +export const IMMEDIATE_DISCARD_CACHE_TIME = 0; // Use it for cache entries that need to be persistent until unused by components or manually deleted + +const _cache = new Map>(); const DELIMITER = '|'; const MIN_LENGTH_TO_HASH = 64; +const CACHE_KEY_SANITIZE_PATTERN = new RegExp('[^\\w\\-_|]', 'g'); + +/** + * Headers that may affect HTTP response content and should be included in cache key generation. + * All header names must be lowercase to match normalized request headers. + */ +const CACHE_KEY_HEADER_WHITELIST = new Set([ + // Content negotiation + 'accept', // Affects response format (e.g. JSON, HTML) + 'accept-language', // Affects localization of the response + 'accept-encoding', // Affects response compression (e.g. gzip, br) + + // Authentication + 'authorization', // Affects access to protected resources + + // Request body metadata + 'content-type', // Affects how the request body is interpreted + + // Optional headers + 'referer', // May influence behavior in some APIs + 'origin', // Relevant in CORS or tenant-specific APIs + 'user-agent', // Included only for reason if server returns client-specific content + + // Cookies — only if server uses session-based responses + 'cookie', // Can fragment cache heavily; use only if necessary + + // Custom headers that may affect response content + 'x-api-key', // Token-based access, often affects authorization + 'x-requested-with', // AJAX requests (used historically for distinguishing frontend calls) + 'x-client-id', // Per-client/partner identity; often used in multi-tenant APIs + 'x-tenant-id', // Multi-tenant segmentation; often changes response per tenant + 'x-user-id', // Explicit user context (less common, but may exist) + + 'x-app-version', // Used for version-specific behavior (e.g. mobile apps) + 'x-feature-flag', // Controls feature rollout behavior server-side + 'x-device-id', // Used when response varies per device/app instance + 'x-platform', // e.g. 'ios', 'android', 'web' — used in apps that serve different content + + 'x-session-id', // Only if backend uses it to affect the response directly (rare) + 'x-locale', // Sometimes used in addition to or instead of `accept-language` +]); /** * Generates a unique cache key for a given URL and fetch options, ensuring that key factors * like method, headers, body, and other options are included in the cache key. * Headers and other objects are sorted by key to ensure consistent cache keys. * - * @param options - The fetch options that may affect the request. The most important are: + * @param {RequestConfig} config - The fetch options that may affect the request. The most important are: * @property {string} [method="GET"] - The HTTP method (GET, POST, etc.). * @property {HeadersInit} [headers={}] - The request headers. * @property {BodyInit | null} [body=""] - The body of the request (only for methods like POST, PUT). - * @property {RequestMode} [mode="cors"] - The mode for the request (e.g., cors, no-cors, include). - * @property {RequestCredentials} [credentials="include"] - Whether to include credentials like cookies. + * @property {RequestCredentials} [credentials="same-origin"] - Whether to include credentials (include, same-origin, omit). * @property {RequestCache} [cache="default"] - The cache mode (e.g., default, no-store, reload). - * @property {RequestRedirect} [redirect="follow"] - How to handle redirects (e.g., follow, error, manual). - * @property {string} [referrer=""] - The referrer URL to send with the request. - * @property {string} [integrity=""] - Subresource integrity value (a cryptographic hash for resource validation). - * @returns {string} - A unique cache key based on the URL and request options. Empty if cache is to be burst. + * @returns {string} - A unique cache key string based on the provided options. * * @example * const cacheKey = generateCacheKey({ @@ -38,42 +89,76 @@ const MIN_LENGTH_TO_HASH = 64; * }); * console.log(cacheKey); */ -export function generateCacheKey(options: FetcherConfig): string { +export function generateCacheKey( + config: RequestConfig, + cacheKeyCheck = true, +): string { + // This is super fast. Effectively a no-op if cacheKey is + // a string or a function that returns a string. + const key = config.cacheKey; + + if (key && cacheKeyCheck) { + return typeof key === STRING + ? (key as string) + : (key as CacheKeyFunction)(config); + } + const { url = '', method = GET, headers = null, - body = undefined, - mode = 'cors', + body = null, credentials = 'same-origin', - cache = 'default', - redirect = 'follow', - referrer = 'about:client', - integrity = '', - } = options; - - // Bail early if cache should be burst - if (cache === 'reload') { - return ''; - } + } = config; // Sort headers and body + convert sorted to strings for hashing purposes // Native serializer is on avg. 3.5x faster than a Fast Hash or FNV-1a let headersString = ''; if (headers) { - const obj = - headers instanceof Headers - ? Object.fromEntries((headers as any).entries()) - : headers; - headersString = shallowSerialize(sortObject(obj)); - if (headersString.length > MIN_LENGTH_TO_HASH) { - headersString = hash(headersString); + let obj: Record; + + if (headers instanceof Headers) { + obj = processHeaders(headers); + } else { + obj = headers as Record; + } + + // Filter headers to only include those that affect request identity + // Include only headers that affect request identity, not execution behavior + const keys = Object.keys(obj); + const len = keys.length; + + // Sort keys manually for fastest deterministic output + if (len > 1) { + keys.sort(); } + + let str = ''; + for (let i = 0; i < len; ++i) { + if (CACHE_KEY_HEADER_WHITELIST.has(keys[i].toLowerCase())) { + str += keys[i] + ':' + obj[keys[i]] + ';'; + } + } + + headersString = hash(str); + } + + // For GET requests, return early with shorter cache key + if (method === GET) { + return ( + method + + DELIMITER + + url + + DELIMITER + + credentials + + DELIMITER + + headersString + ).replace(CACHE_KEY_SANITIZE_PATTERN, ''); } let bodyString = ''; if (body) { - if (typeof body === 'string') { + if (typeof body === STRING) { bodyString = body.length < MIN_LENGTH_TO_HASH ? body : hash(body); // hash only if large } else if (body instanceof FormData) { body.forEach((value, key) => { @@ -92,10 +177,9 @@ export function generateCacheKey(options: FetcherConfig): string { } else if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { bodyString = 'AB' + body.byteLength; } else { - const o = - typeof body === OBJECT - ? JSON.stringify(sortObject(body)) - : String(body); + const o = isObject(body) + ? JSON.stringify(sortObject(body)) + : String(body); bodyString = o.length > MIN_LENGTH_TO_HASH ? hash(o) : o; } @@ -108,101 +192,120 @@ export function generateCacheKey(options: FetcherConfig): string { DELIMITER + url + DELIMITER + - mode + credentials + - cache + - redirect + - referrer + - integrity + DELIMITER + headersString + DELIMITER + bodyString - ).replace(/[^\w\-_|]/g, ''); // Prevent cache poisoning by removal of anything that isn't letters, numbers, -, _, or | + ).replace(CACHE_KEY_SANITIZE_PATTERN, ''); // Prevent cache poisoning by removal of anything that isn't letters, numbers, -, _, or | } /** - * Checks if the cache entry is expired based on its timestamp and the maximum stale time. + * Checks if the cache entry is expired based on its timestamp and the expiry time. * - * @param {number} timestamp - The timestamp of the cache entry. - * @param {number} maxStaleTime - The maximum stale time in seconds. + * @param {CacheEntry} entry - The cache entry to check. * @returns {boolean} - Returns true if the cache entry is expired, false otherwise. */ -function isCacheExpired(timestamp: number, maxStaleTime: number): boolean { - if (!maxStaleTime) { +function isCacheExpired(entry: CacheEntry): boolean { + // No expiry time means the entry never expires + if (!entry.expiry) { return false; } - return Date.now() - timestamp > maxStaleTime * 1000; + return timeNow() > entry.expiry; } /** - * Retrieves a cache entry if it exists and is not expired. + * Checks if the cache entry is stale based on its timestamp and the stale time. * - * @param {string} key Cache key to utilize - * @param {number} cacheTime - Maximum time to cache entry in seconds. - * @returns {CacheEntry | null} - The cache entry if it exists and is not expired, null otherwise. + * @param {CacheEntry} entry - The cache entry to check. + * @returns {boolean} - Returns true if the cache entry is stale, false otherwise. */ -export function getCache( - key: string, - cacheTime: number, -): CacheEntry | null { - const entry = cache.get(key); +function isCacheStale(entry: CacheEntry): boolean { + if (!entry.stale) { + return false; + } - if (entry) { - if (!isCacheExpired(entry.timestamp, cacheTime)) { - return entry; - } + return timeNow() > entry.stale; +} - deleteCache(key); +/** + * Retrieves a cached response from the internal cache using the provided key. + * + * @param key - The unique key identifying the cached entry. If null, returns null. + * @returns The cached {@link FetchResponse} if found, otherwise null. + */ +export function getCacheData< + ResponseData, + RequestBody, + QueryParams, + PathParams, +>( + key: string | null, +): FetchResponse | null { + if (!key) { + return null; } - return null; + const entry = _cache.get(key); + + return entry ? entry.data : null; } /** - * Sets a new cache entry or updates an existing one. + * Retrieves a cache entry if it exists and is not expired. + * + * @param {string} key Cache key to utilize + * @returns {CacheEntry | null} - The cache entry if it exists and is not expired, null otherwise. + */ +export function getCache( + key: string | null, +): + | CacheEntry< + FetchResponse + > + | null + | undefined { + return _cache.get(key as string); +} + +/** + * Sets a new cache entry or updates an existing one, with optional TTL (time-to-live). * * @param {string} key Cache key to utilize * @param {T} data - The data to be cached. - * @param {boolean} isLoading - Indicates if the data is currently being fetched. + * @param {number} [ttl] - Optional TTL in seconds. If not provided, the cache entry will not expire. + * @param {number} [staleTime] - Optional stale time in seconds. If provided, the cache entry will be considered stale after this time. */ export function setCache( key: string, data: T, - isLoading: boolean = false, + ttl?: number, + staleTime?: number, ): void { - cache.set(key, { + if (ttl === 0) { + deleteCache(key); + return; + } + + const time = timeNow(); + const ttlMs = ttl ? ttl * 1000 : 0; + + _cache.set(key, { data, - isLoading, - timestamp: Date.now(), + time, + stale: staleTime && staleTime > 0 ? time + staleTime * 1000 : staleTime, + expiry: ttl === -1 ? undefined : time + ttlMs, }); -} -/** - * Revalidates a cache entry by fetching fresh data and updating the cache. - * - * @param {string} key Cache key to utilize - * @param {FetcherConfig} config - The request configuration object. - * @returns {Promise} - A promise that resolves when the revalidation is complete. - */ -export async function revalidate( - key: string, - config: FetcherConfig, -): Promise { - try { - // Fetch fresh data - const newData = await fetchf(config.url, { - ...config, - cache: 'reload', - }); - - setCache(key, newData); - } catch (error) { - console.error(`Error revalidating ${config.url}:`, error); - - // Rethrow the error to forward it - throw error; + if (ttlMs > 0) { + addTimeout( + 'c:' + key, + () => { + deleteCache(key, true); + }, + ttlMs, + ); } } @@ -210,42 +313,83 @@ export async function revalidate( * Invalidates (deletes) a cache entry. * * @param {string} key Cache key to utilize + * @param {boolean} [removeExpired=false] - If true, only deletes the cache entry if it is expired or stale. */ -export function deleteCache(key: string): void { - cache.delete(key); +export function deleteCache(key: string, removeExpired: boolean = false): void { + if (removeExpired) { + const entry = getCache(key); + + // If the entry does not exist, or it is neither expired nor stale, do not delete + if (!entry || !isCacheExpired(entry)) { + return; + } + } + + _cache.delete(key); } /** * Prunes the cache by removing entries that have expired based on the provided cache time. - * @param cacheTime - The maximum time to cache entry. */ -export function pruneCache(cacheTime: number) { - cache.forEach((entry, key) => { - if (isCacheExpired(entry.timestamp, cacheTime)) { - cache.delete(key); - } - }); +export function pruneCache(): void { + _cache.clear(); } /** * Mutates a cache entry with new data and optionally revalidates it. * - * @param {string} key Cache key to utilize - * @param {FetcherConfig} config - The request configuration object. - * @param {T} newData - The new data to be cached. - * @param {boolean} revalidateAfter - If true, triggers revalidation after mutation. + * @param {string | null} key Cache key to utilize. If null, no mutation occurs. + * @param {ResponseData} newData - The new data to be cached. + * @param {MutationSettings|undefined} settings - Mutation settings. */ -export function mutate( - key: string, - config: FetcherConfig, - newData: T, - revalidateAfter: boolean = false, -): void { - setCache(key, newData); +export async function mutate< + ResponseData = DefaultResponse, + RequestBody = DefaultPayload, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, +>( + key: string | null, + newData: ResponseData, + settings?: MutationSettings, +): Promise | null> { + // If no key is provided, do nothing + if (!key) { + return null; + } + + const entry = getCache( + key, + ); - if (revalidateAfter) { - revalidate(key, config); + if (!entry) { + return null; } + + const updatedData = isObject(newData) ? sanitizeObject(newData) : newData; + + const updatedResponse = { + ...entry.data, + data: updatedData, + }; + + const updatedEntry = { + ...entry, + data: updatedResponse, + }; + + _cache.set(key, updatedEntry); + notifySubscribers(key, updatedResponse); + + if (settings && settings.refetch) { + return await revalidate(key); + } + + return null; } /** @@ -257,8 +401,7 @@ export function mutate( * @template PathParams - The type of the path parameters. * @param {string | null} cacheKey - The cache key to look up. * @param {number | undefined} cacheTime - The maximum time to cache entry. - * @param {(cfg: any) => boolean | undefined} cacheBuster - Optional function to determine if cache should be bypassed. - * @param {FetcherConfig} fetcherConfig - The fetcher configuration. + * @param {RequestConfig} requestConfig - The fetcher configuration. * @returns {FetchResponse | null} - The cached response or null. */ export function getCachedResponse< @@ -269,8 +412,7 @@ export function getCachedResponse< >( cacheKey: string | null, cacheTime: number | undefined, - cacheBuster: ((cfg: any) => boolean) | undefined, - fetcherConfig: FetcherConfig< + requestConfig: RequestConfig< ResponseData, QueryParams, PathParams, @@ -278,20 +420,98 @@ export function getCachedResponse< >, ): FetchResponse | null { // If cache key or time is not provided, return null - if (!cacheTime || !cacheKey) { + if (!cacheKey || cacheTime === undefined || cacheTime === null) { return null; } // Check if cache should be bypassed - if (cacheBuster?.(fetcherConfig)) { + const buster = requestConfig.cacheBuster || defaultConfig.cacheBuster; + if (buster && buster(requestConfig)) { return null; } + if (requestConfig.cache && requestConfig.cache === 'reload') { + return null; // Skip cache lookup entirely + } + // Retrieve the cached entry - const cachedEntry = getCache< - FetchResponse - >(cacheKey, cacheTime); + const entry = getCache( + cacheKey, + ); + + if (!entry) { + return null; + } + + const isExpired = isCacheExpired(entry); + const isStale = isCacheStale(entry); + + // If completely expired, delete and return null + if (isExpired) { + deleteCache(cacheKey); + return null; + } + + // If fresh (not stale), return immediately + if (!isStale) { + return entry.data; + } + + // SWR: Data is stale but not expired + if (isStale && !isExpired) { + // Triggering background revalidation here could cause race conditions + // So we return stale data immediately and leave it up to implementers to handle revalidation + return entry.data; + } + + return null; +} - // If no cached entry or it is expired, return null - return cachedEntry ? cachedEntry.data : null; +/** + * Sets or deletes the response cache based on cache settings and notifies subscribers. + * + * @param {FetchResponse} output - The response to cache. + * @param {RequestConfig} requestConfig - The request configuration. + * @param {boolean} [isError=false] - Whether the response is an error. + */ +export function handleResponseCache< + ResponseData = DefaultResponse, + RequestBody = DefaultPayload, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, +>( + output: FetchResponse, + requestConfig: RequestConfig< + ResponseData, + QueryParams, + PathParams, + RequestBody + >, + isError: boolean = false, +): void { + // It is string as it is called once request is made + const cacheKey = requestConfig.cacheKey as string; + + if (cacheKey) { + const cacheTime = requestConfig.cacheTime; + const skipCache = requestConfig.skipCache; + + // Fast path: only set cache if cacheTime is positive and not skipping cache + if ( + cacheTime && + (!isError || requestConfig.cacheErrors) && + !(skipCache && skipCache(output, requestConfig)) + ) { + setCache(cacheKey, output, cacheTime, requestConfig.staleTime); + } + + notifySubscribers(cacheKey, output); + removeInFlight(cacheKey); + + const prevCacheKey = requestConfig._prevKey; + + if (prevCacheKey) { + removeInFlight(prevCacheKey); + } + } } diff --git a/src/config-handler.ts b/src/config-handler.ts index 6479af8b..f2534cdd 100644 --- a/src/config-handler.ts +++ b/src/config-handler.ts @@ -5,35 +5,38 @@ import { STRING, CHARSET_UTF_8, CONTENT_TYPE, - OBJECT, + REJECT, + UNDEFINED, + APPLICATION_CONTENT_TYPE, } from './constants'; import type { - FetcherConfig, HeadersObject, Method, RequestConfig, - RequestHandlerConfig, } from './types/request-handler'; import { replaceUrlPathParams, appendQueryParams, isSearchParams, isJSONSerializable, + isSlowConnection, + isAbsoluteUrl, + sanitizeObject, + isObject, } from './utils'; -export const defaultConfig: RequestHandlerConfig = { - method: GET, - strategy: 'reject', - timeout: 30000, // 30 seconds - dedupeTime: 0, - defaultResponse: null, +const defaultTimeoutMs = (isSlowConnection() ? 60 : 30) * 1000; + +export const defaultConfig: RequestConfig = { + strategy: REJECT, + timeout: defaultTimeoutMs, // 30 seconds (60 on slow connections) headers: { Accept: APPLICATION_JSON + ', text/plain, */*', 'Accept-Encoding': 'gzip, deflate, br', }, retry: { - delay: 1000, - maxDelay: 30000, + delay: defaultTimeoutMs / 30, // 1 second (2 on slow connections) + maxDelay: defaultTimeoutMs, // 30 seconds (60 on slow connections) resetTimeout: true, backoff: 1.5, @@ -52,28 +55,83 @@ export const defaultConfig: RequestHandlerConfig = { }; /** - * Build request configuration + * Overwrites the default configuration with the provided custom configuration. + * + * @param {Partial} customConfig - The custom configuration to merge into the default config. + * @returns {Partial} - The updated default configuration object. + */ +export function setDefaultConfig( + customConfig: Partial, +): Partial { + const sanitized = sanitizeObject(customConfig); + + Object.assign(defaultConfig, sanitized); + + return defaultConfig; +} + +/** + * Returns a shallow copy of the current default configuration. * + * @returns {RequestConfig} - The current default configuration. + */ +export function getDefaultConfig(): RequestConfig { + return { ...defaultConfig }; +} + +/** + * Build request configuration from defaults and overrides. + * This function merges the default configuration with the provided request configuration, * @param {string} url - Request url - * @param {RequestConfig} requestConfig - Request config passed when making the request - * @returns {FetcherConfig} - Provider's instance + * @param {RequestConfig | null | undefined} reqConfig - Request configuration + * @return {RequestConfig} - Merged request configuration */ -export const buildConfig = ( +export function buildConfig( + url: string, + reqConfig?: RequestConfig< + ResponseData, + QueryParams, + PathParams, + RequestBody + > | null, +): RequestConfig { + if (!reqConfig) { + return buildFetcherConfig(url, getDefaultConfig()); + } + + const sanitized = sanitizeObject(reqConfig); + const merged = mergeConfigs(defaultConfig, sanitized); + + return buildFetcherConfig(url, merged); +} + +/** + * Builds the fetcher configuration by setting the method, body, headers, and URL. + * It also handles query parameters and path parameters. This fn mutates the passed `requestConfig` object. + * @param {string} url - The endpoint URL to which the request will be sent. + * @param {RequestConfig} requestConfig - The request configuration object containing method, body, headers, and other options. + * @return {RequestConfig} - The modified request configuration object with the URL, method, body, and headers set appropriately. + **/ +export function buildFetcherConfig( url: string, requestConfig: RequestConfig, -): FetcherConfig => { - const method = (requestConfig.method ?? GET).toUpperCase() as Method; - const isGetAlikeMethod = method === GET || method === HEAD; - const dynamicUrl = replaceUrlPathParams(url, requestConfig.urlPathParams); +): RequestConfig { + let method = requestConfig.method as Method; + method = method ? (method.toUpperCase() as Method) : GET; let body: RequestConfig['data'] | undefined; // Only applicable for request methods 'PUT', 'POST', 'DELETE', and 'PATCH' - if (!isGetAlikeMethod) { + if (method !== GET && method !== HEAD) { body = requestConfig.body ?? requestConfig.data; + + // Automatically stringify request body, if possible and when not dealing with strings + if (body && typeof body !== STRING && isJSONSerializable(body)) { + body = JSON.stringify(body); + } } - setContentTypeIfNeeded(method, requestConfig.headers, body); + setContentTypeIfNeeded(requestConfig.headers, body); // Native fetch compatible settings const credentials = requestConfig.withCredentials @@ -81,34 +139,20 @@ export const buildConfig = ( : requestConfig.credentials; // The explicitly passed query params - const explicitParams = requestConfig.params; - - const urlPath = explicitParams - ? appendQueryParams(dynamicUrl, explicitParams) - : dynamicUrl; - const isFullUrl = urlPath.includes('://'); + const dynamicUrl = replaceUrlPathParams(url, requestConfig.urlPathParams); + const urlPath = appendQueryParams(dynamicUrl, requestConfig.params); + const isFullUrl = isAbsoluteUrl(url); const baseURL = isFullUrl ? '' - : (requestConfig.baseURL ?? requestConfig.apiUrl); + : requestConfig.baseURL || requestConfig.apiUrl || ''; - // Automatically stringify request body, if possible and when not dealing with strings - if ( - body && - typeof body !== STRING && - !isSearchParams(body) && - isJSONSerializable(body) - ) { - body = JSON.stringify(body); - } + requestConfig.url = baseURL + urlPath; + requestConfig.method = method; + requestConfig.credentials = credentials; + requestConfig.body = body; - return { - ...requestConfig, - url: baseURL + urlPath, - method, - credentials, - body, - }; -}; + return requestConfig; +} /** * Ensures the `Content-Type` header is set to `application/json; charset=utf-8` @@ -116,52 +160,134 @@ export const buildConfig = ( * * @param headers - The headers object to modify. Can be an instance of `Headers` * or a plain object conforming to `HeadersInit`. - * @param method - The HTTP method of the request (e.g., 'PUT', 'DELETE', etc.). * @param body - The optional body of the request. If no body is provided and the - * method is 'PUT' or 'DELETE', the function exits without modifying headers. + * method is 'GET' or 'HEAD', the function exits without modifying headers. */ -const setContentTypeIfNeeded = ( - method: string, +function setContentTypeIfNeeded( headers?: HeadersInit | HeadersObject, body?: unknown, -): void => { - if (!headers || (!body && ['PUT', 'DELETE'].includes(method))) { +): void { + // If no headers are provided, or if the body is not set and the method is PUT or DELETE, do nothing + if (!headers || !body) { + return; + } + + // Types that should not have Content-Type set (browser handles these) + if ( + body instanceof FormData || // Browser automatically sets multipart/form-data with boundary + (typeof Blob !== UNDEFINED && body instanceof Blob) || // Blob/File already have their own MIME types, don't override + (typeof File !== UNDEFINED && body instanceof File) || + (typeof ReadableStream !== UNDEFINED && body instanceof ReadableStream) // Stream type should be determined by the stream source + ) { return; } - const contentTypeValue = APPLICATION_JSON + ';' + CHARSET_UTF_8; + let contentTypeValue: string; + + if (isSearchParams(body)) { + contentTypeValue = APPLICATION_CONTENT_TYPE + 'x-www-form-urlencoded'; + } else if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { + contentTypeValue = APPLICATION_CONTENT_TYPE + 'octet-stream'; + } else if (isJSONSerializable(body)) { + contentTypeValue = APPLICATION_JSON + ';' + CHARSET_UTF_8; + } else { + // Do not set Content-Type if content is not recognizable + return; + } if (headers instanceof Headers) { if (!headers.has(CONTENT_TYPE)) { headers.set(CONTENT_TYPE, contentTypeValue); } } else if ( - typeof headers === OBJECT && + isObject(headers) && !Array.isArray(headers) && !headers[CONTENT_TYPE] ) { headers[CONTENT_TYPE] = contentTypeValue; } -}; +} + +export function mergeConfigs( + baseConfig: RequestConfig, + overrideConfig: RequestConfig, +): RequestConfig { + const mergedConfig: RequestConfig = Object.assign( + {}, + baseConfig, + overrideConfig, + ); + + // Ensure that retry and headers are merged correctly + mergeConfig('retry', mergedConfig, baseConfig, overrideConfig); + mergeConfig('headers', mergedConfig, baseConfig, overrideConfig); + + // Merge interceptors efficiently + mergeInterceptors('onRequest', mergedConfig, baseConfig, overrideConfig); + mergeInterceptors('onResponse', mergedConfig, baseConfig, overrideConfig); + mergeInterceptors('onError', mergedConfig, baseConfig, overrideConfig); + + return mergedConfig; +} /** - * Merges the specified property from the base configuration and the new configuration into the target configuration. + * Efficiently merges interceptor functions from base and new configs + */ +function mergeInterceptors< + K extends 'onRequest' | 'onResponse' | 'onError' | 'onRetry', +>( + property: K, + targetConfig: RequestConfig, + baseConfig: RequestConfig, + overrideConfig: RequestConfig, +): void { + const baseInterceptor = baseConfig[property]; + const newInterceptor = overrideConfig[property]; + + if (!baseInterceptor && !newInterceptor) { + return; + } + + if (!baseInterceptor) { + targetConfig[property] = newInterceptor; + return; + } + + if (!newInterceptor) { + targetConfig[property] = baseInterceptor; + return; + } + + const baseArr = Array.isArray(baseInterceptor) + ? baseInterceptor + : [baseInterceptor]; + const newArr = Array.isArray(newInterceptor) + ? newInterceptor + : [newInterceptor]; + + // This is the only LIFO interceptor, so we apply it after the response is prepared + targetConfig[property] = + property === 'onResponse' ? newArr.concat(baseArr) : baseArr.concat(newArr); +} + +/** + * Merges the specified property from the base configuration and the override configuration into the target configuration. * - * @param {K} property - The property key to merge from the base and new configurations. Must be a key of RequestHandlerConfig. - * @param {RequestHandlerConfig} targetConfig - The configuration object that will receive the merged properties. - * @param {RequestHandlerConfig} baseConfig - The base configuration object that provides default values. - * @param {RequestHandlerConfig} newConfig - The new configuration object that contains user-specific settings to merge. + * @param {K} property - The property key to merge from the base and override configurations. Must be a key of RequestConfig. + * @param {RequestConfig} targetConfig - The configuration object that will receive the merged properties. + * @param {RequestConfig} baseConfig - The base configuration object that provides default values. + * @param {RequestConfig} overrideConfig - The override configuration object that contains user-specific settings to merge. */ -export const mergeConfig = ( +export function mergeConfig( property: K, - targetConfig: RequestHandlerConfig, - baseConfig: RequestHandlerConfig, - newConfig: RequestHandlerConfig, -) => { - if (newConfig[property]) { + targetConfig: RequestConfig, + baseConfig: RequestConfig, + overrideConfig: RequestConfig, +): void { + if (overrideConfig[property]) { targetConfig[property] = { ...baseConfig[property], - ...newConfig[property], + ...overrideConfig[property], }; } -}; +} diff --git a/src/constants.ts b/src/constants.ts index 5fe8afa0..98081e26 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -11,7 +11,8 @@ export const FUNCTION = 'function'; export const ABORT_ERROR = 'AbortError'; export const TIMEOUT_ERROR = 'TimeoutError'; -export const CANCELLED_ERROR = 'CanceledError'; export const GET = 'GET'; export const HEAD = 'HEAD'; + +export const REJECT = 'reject'; diff --git a/src/error-handler.ts b/src/error-handler.ts new file mode 100644 index 00000000..d44ce81e --- /dev/null +++ b/src/error-handler.ts @@ -0,0 +1,124 @@ +import type { ResponseError } from './errors/response-error'; +import type { + DefaultResponse, + FetchResponse, + RequestConfig, +} from './types/request-handler'; +import { applyInterceptors } from './interceptor-manager'; +import { handleResponseCache } from './cache-manager'; +import { ABORT_ERROR, REJECT } from './constants'; +import { DefaultParams, DefaultUrlParams, DefaultPayload } from './types'; + +/** + * Handles final processing for both success and error responses + * Applies error interceptors, caching, notifications, and error strategy + */ +export async function withErrorHandling< + ResponseData = DefaultResponse, + RequestBody = DefaultPayload, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, +>( + isStaleRevalidation: boolean, + requestFn: ( + isStaleRevalidation: boolean, + ) => Promise< + FetchResponse + >, + requestConfig: RequestConfig< + ResponseData, + QueryParams, + PathParams, + RequestBody + >, +): Promise> { + const output = await requestFn(isStaleRevalidation); + const error = output.error; + + if (!error) { + // SUCCESS PATH + handleResponseCache(output, requestConfig); + + return output; + } + + // ERROR PATH + + if (requestConfig.onError) { + await applyInterceptors(requestConfig.onError, error); + } + + // Timeouts and request cancellations using AbortController do not throw any errors unless rejectCancelled is true. + // Only handle the error if the request was not cancelled, or if it was cancelled and rejectCancelled is true. + const isCancelled = error.isCancelled; + + if (!isCancelled && requestConfig.logger) { + logger(requestConfig, 'FETCH ERROR', error as ResponseError); + } + + // Handle cache and notifications FIRST (before strategy) + handleResponseCache(output, requestConfig, true); + + // handle error strategy as the last part + const shouldHandleError = !isCancelled || requestConfig.rejectCancelled; + + if (shouldHandleError) { + const strategy = requestConfig.strategy; + // Reject the promise + if (strategy === REJECT) { + return Promise.reject(error); + } + + // Hang the promise + if (strategy === 'silent') { + await new Promise(() => null); + } + } + + return output; +} + +export function enhanceError< + ResponseData = DefaultResponse, + RequestBody = DefaultPayload, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, +>( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error: any, + response: FetchResponse< + ResponseData, + RequestBody, + QueryParams, + PathParams + > | null, + requestConfig: RequestConfig< + ResponseData, + QueryParams, + PathParams, + RequestBody + >, +): void { + error.status = error.status || response?.status || 0; + error.statusText = error.statusText || response?.statusText || ''; + error.config = error.request = requestConfig; + error.response = response; + error.isCancelled = error.name === ABORT_ERROR; +} + +/** + * Logs messages or errors using the configured logger's `warn` method. + * + * @param {RequestConfig} reqConfig - Request config passed when making the request + * @param {...(string | ResponseError)} args - Messages or errors to log. + */ +function logger( + reqConfig: RequestConfig, + ...args: (string | ResponseError)[] +): void { + const logger = reqConfig.logger; + + if (logger && logger.warn) { + logger.warn(...args); + } +} diff --git a/src/errors/fetch-error.ts b/src/errors/fetch-error.ts index 41858b92..bea4d873 100644 --- a/src/errors/fetch-error.ts +++ b/src/errors/fetch-error.ts @@ -12,13 +12,14 @@ import type { */ export class FetchError< ResponseData = DefaultResponse, + RequestBody = DefaultPayload, QueryParams = DefaultParams, PathParams = DefaultUrlParams, - RequestBody = DefaultPayload, > extends Error { status: number; statusText: string; config: RequestConfig; + isCancelled: boolean; constructor( message: string, @@ -38,8 +39,9 @@ export class FetchError< super(message); this.name = 'FetchError'; - this.status = response?.status || 0; - this.statusText = response?.statusText || ''; + this.status = response ? response.status : 0; + this.statusText = response ? response.statusText : ''; this.config = request; + this.isCancelled = false; } } diff --git a/src/errors/network-error.ts b/src/errors/network-error.ts index 5039303c..294493de 100644 --- a/src/errors/network-error.ts +++ b/src/errors/network-error.ts @@ -9,10 +9,10 @@ import type { export class NetworkError< ResponseData = DefaultResponse, + RequestBody = DefaultPayload, QueryParams = DefaultParams, PathParams = DefaultUrlParams, - RequestBody = DefaultPayload, -> extends FetchError { +> extends FetchError { constructor( message: string, request: RequestConfig, diff --git a/src/errors/response-error.ts b/src/errors/response-error.ts index 526ea475..2ad0db9b 100644 --- a/src/errors/response-error.ts +++ b/src/errors/response-error.ts @@ -10,14 +10,19 @@ import type { export class ResponseError< ResponseData = DefaultResponse, + RequestBody = DefaultPayload, QueryParams = DefaultParams, PathParams = DefaultUrlParams, - RequestBody = DefaultPayload, -> extends FetchError { +> extends FetchError { constructor( message: string, request: RequestConfig, - response: FetchResponse | null, + response: FetchResponse< + ResponseData, + RequestBody, + QueryParams, + PathParams + > | null, ) { super(message, request, response); diff --git a/src/index.ts b/src/index.ts index 57065320..b3c39351 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,25 +1,44 @@ -import { createRequestHandler } from './request-handler'; -import type { - DefaultResponse, - FetchResponse, - RequestHandlerConfig, -} from './types'; - -/** - * Simple wrapper for request fetching. - * It abstracts the creation of RequestHandler, making it easy to perform API requests. - * - * @param {string | URL | globalThis.Request} url - Request URL. - * @param {RequestHandlerConfig} config - Configuration object for the request handler. - * @returns {Promise>} Response Data. - */ -export async function fetchf( - url: string, - config: RequestHandlerConfig = {}, -): Promise> { - return createRequestHandler(config).request(url); -} +/** All TypeScript types and interfaces */ +export * from './types'; + +/** Core fetch function with caching, retries, and revalidation */ +export { fetchf, fetchf as fetchff } from './request-handler'; +/** Create a configured API fetcher instance */ export { createApiFetcher } from './api-handler'; -export * from './types'; +/** Build and merge request configurations */ +export { + buildConfig, // Build request configuration from defaults and overrides + setDefaultConfig, // Set global default configuration for requests + getDefaultConfig, // Get the current global default configuration +} from './config-handler'; + +/** Cache management utilities */ +export { + generateCacheKey, // Generate cache key from URL and config + getCache, // Get cached response for a key + getCachedResponse, // Get cached response with revalidation + mutate, // Update cache and notify subscribers + setCache, // Set cache entry directly + deleteCache, // Delete cache entry +} from './cache-manager'; + +/** Request Revalidation management for cache freshness */ +export { + revalidate, // Revalidate specific cache entry + revalidateAll, // Revalidate all entries by event type + removeRevalidators, // Clean up all revalidators by type +} from './revalidator-manager'; + +/** Subscribe to cache updates via pub/sub */ +export { subscribe } from './pubsub-manager'; + +/** Abort in-flight requests and check request status */ +export { abortRequest, getInFlightPromise } from './inflight-manager'; + +/** Network and environment utilities (Browser Only) */ +export { isSlowConnection } from './utils'; + +/** Timeout management for delayed operations */ +export { addTimeout } from './timeout-wheel'; diff --git a/src/inflight-manager.ts b/src/inflight-manager.ts new file mode 100644 index 00000000..dc70cbfa --- /dev/null +++ b/src/inflight-manager.ts @@ -0,0 +1,215 @@ +/** + * @module inflight-manager + * + * Manages in-flight asynchronous requests using unique keys to enable deduplication and cancellation. + * + * Provides utilities for: + * - Deduplication of requests within a configurable time window (`dedupeTime`) + * - Timeout management and automatic request abortion + * - AbortController lifecycle and cancellation logic + * - Concurrency control and request state tracking + * - In-flight promise deduplication to prevent duplicate network calls + * + * @remarks + * - Requests with the same key within the deduplication interval share the same AbortController and in-flight promise. + * - Supports cancellation of previous requests when a new one with the same key is issued, if `isCancellable` is enabled. + * - Timeout logic ensures requests are aborted after a specified duration, if enabled. + * - Internal queue state is managed via a Map, keyed by request identifier. + * - Polled requests are also marked as "in-flight" to prevent duplicate requests. + */ + +import { ABORT_ERROR, TIMEOUT_ERROR } from './constants'; +import { addTimeout, removeTimeout } from './timeout-wheel'; +import { timeNow } from './utils'; + +export type InFlightItem = [ + AbortController, // AbortController for the request + boolean, // Whether timeout is enabled for the request + number, // Timestamp when the request was marked in-flight + boolean, // isCancellable - whether the request can be cancelled + Promise | null, // Optional in-flight promise for deduplication +]; + +const inFlight: Map = new Map(); + +/** + * Adds a request to the queue if it's not already being processed within the dedupeTime interval. + * + * @param {string | null} key - Unique key for the request (e.g. cache key). + * @param {string} url - The request URL (for error messages/timeouts). + * @param {number} timeout - Timeout in milliseconds for the request. + * @param {number} dedupeTime - Deduplication time in milliseconds. + * @param {boolean} isCancellable - If true, then the previous request with same configuration should be aborted. + * @param {boolean} isTimeoutEnabled - Whether timeout is enabled. + * @returns {AbortController} - A promise that resolves to an AbortController. + */ +export function markInFlight( + key: string | null, + url: string, + timeout: number | undefined, + dedupeTime: number, + isCancellable: boolean, + isTimeoutEnabled: boolean, +): AbortController { + if (!key) { + return new AbortController(); + } + + const item = inFlight.get(key); + let prevPromise: Promise | null = null; + + // Previous request is in-flight, check if we can reuse it + if (item) { + const prevController = item[0]; + const prevIsCancellable = item[3]; + + // If the request is already in the queue and within the dedupeTime, reuse the existing controller + if ( + !prevIsCancellable && + timeNow() - item[2] < dedupeTime && + !prevController.signal.aborted + ) { + return prevController; + } + + // If the request is too old, remove it and proceed to add a new one + // Abort previous request, if applicable, and continue as usual + if (prevIsCancellable) { + prevController.abort( + new DOMException('Aborted due to new request', ABORT_ERROR), + ); + } + + removeTimeout(key); + prevPromise = item[4]; + } + + const controller = new AbortController(); + + inFlight.set(key, [ + controller, + isTimeoutEnabled, + timeNow(), + isCancellable, + prevPromise, + ]); + + if (isTimeoutEnabled) { + addTimeout( + key, + () => { + abortRequest( + key, + new DOMException(url + ' aborted due to timeout', TIMEOUT_ERROR), + ); + }, + timeout as number, + ); + } + + return controller; +} + +/** + * Removes a request from the queue and clears its timeout. + * + * @param key - Unique key for the request. + * @param {boolean} error - Optional error to abort the request with. If null, the request is simply removed but no abort sent. + * @returns {Promise} - A promise that resolves when the request is aborted and removed. + */ +export async function abortRequest( + key: string | null, + error: DOMException | null | string = null, +): Promise { + // If the key is not in the queue, there's nothing to remove + if (key) { + const item = inFlight.get(key); + + if (item) { + // If the request is not yet aborted, abort it with the provided error + if (error) { + const controller = item[0]; + controller.abort(error); + } + + removeInFlight(key); + } + } +} + +/** + * Removes a request from the in-flight queue without aborting or clearing timeout. + * + * @param key - Unique key for the request. + */ +export function removeInFlight(key: string | null): void { + removeTimeout(key!); + inFlight.delete(key!); +} + +/** + * Gets the AbortController for a request key. + * + * @param key - Unique key for the request. + * @returns {AbortController | undefined} - The AbortController or undefined. + */ +export async function getController( + key: string, +): Promise { + const item = inFlight.get(key); + + return item?.[0]; +} + +/** + * Adds helpers for in-flight promise deduplication. + * + * @param key - Unique key for the request. + * @param promise - The promise to store. + */ +export function setInFlightPromise( + key: string, + promise: Promise, +): void { + const item = inFlight.get(key); + if (item) { + // store the promise at index 4 + item[4] = promise; + + inFlight.set(key, item); + } +} + +/** + * Retrieves the in-flight promise for a request key if it exists and is within the dedupeTime interval. + * + * @param key - Unique key for the request. + * @param dedupeTime - Deduplication time in milliseconds. + * @returns {Promise | null} - The in-flight promise or null. + */ +export function getInFlightPromise( + key: string | null, + dedupeTime: number, +): Promise | null { + if (!key) { + return null; + } + + const prevReq = inFlight.get(key); + + if ( + prevReq && + // If the request is in-flight and has a promise + prevReq[4] && + // If the request is cancellable, we will not reuse it + !prevReq[3] && + // If the request is within the dedupeTime + timeNow() - prevReq[2] < dedupeTime && + // If one request is cancelled, ALL deduped requests get cancelled + !prevReq[0].signal.aborted + ) { + return prevReq[4] as Promise; + } + + return null; +} diff --git a/src/interceptor-manager.ts b/src/interceptor-manager.ts index be2d918d..dcc979c6 100644 --- a/src/interceptor-manager.ts +++ b/src/interceptor-manager.ts @@ -1,36 +1,45 @@ -type InterceptorFunction = (object: T) => Promise; +import { FUNCTION } from './constants'; +import type { InterceptorFunction } from './types/interceptor-manager'; +import { isObject } from './utils'; /** * Applies interceptors to the object. Interceptors can be a single function or an array of functions. * * @template T - Type of the object. + * @template Args - Type of additional arguments. * @template I - Type of interceptors. * - * @param {T} object - The object to process. - * @param {InterceptorFunction | InterceptorFunction[]} [interceptors] - Interceptor function(s). + * @param {InterceptorFunction | InterceptorFunction[]} [interceptors] - Interceptor function(s). + * @param {T} data - The data object to process. + * @param {...Args} args - Additional arguments to pass to interceptors. * * @returns {Promise} - Nothing as the function is non-idempotent. */ -export async function applyInterceptor< +export async function applyInterceptors< T extends object, - I = InterceptorFunction | InterceptorFunction[], ->(object: T, interceptors?: I): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + Args extends any[] = any[], + I = InterceptorFunction | InterceptorFunction[], +>(interceptors: I | undefined, data: T, ...args: Args): Promise { if (!interceptors) { return; } - if (typeof interceptors === 'function') { - const value = await interceptors(object); + if (typeof interceptors === FUNCTION) { + const value = await (interceptors as InterceptorFunction)( + data, + ...args, + ); - if (value) { - Object.assign(object, value); + if (value && isObject(data) && isObject(value)) { + Object.assign(data, value); } } else if (Array.isArray(interceptors)) { for (const interceptor of interceptors) { - const value = await interceptor(object); + const value = await interceptor(data, ...args); - if (value) { - Object.assign(object, value); + if (value && isObject(data) && isObject(value)) { + Object.assign(data, value); } } } diff --git a/src/polling-handler.ts b/src/polling-handler.ts index 9903e073..c073136b 100644 --- a/src/polling-handler.ts +++ b/src/polling-handler.ts @@ -1,4 +1,4 @@ -import type { ExtendedRequestConfig, FetchResponse } from './types'; +import type { RequestConfig, FetchResponse } from './types'; import { delayInvocation } from './utils'; /** @@ -6,7 +6,7 @@ import { delayInvocation } from './utils'; * pollingInterval is not set, or maxAttempts is reached. * * @template Output The type of the output returned by the request function. - * @param doRequestOnce - The function that performs a single request (with retries). + * @param requestFn - The function that performs a single request (with retries). * @param pollingInterval - Interval in ms between polling attempts. * @param shouldStopPolling - Function to determine if polling should stop. * @param maxAttempts - Maximum number of polling attempts, default: 0 (unlimited). @@ -14,28 +14,35 @@ import { delayInvocation } from './utils'; * @returns The final output from the last request. */ export async function withPolling< - Output extends FetchResponse< - unknown, - unknown, - unknown, - unknown - > = FetchResponse, + ResponseData, + RequestBody, + QueryParams, + PathParams, >( - doRequestOnce: () => Promise, - pollingInterval?: ExtendedRequestConfig['pollingInterval'], - shouldStopPolling?: ExtendedRequestConfig['shouldStopPolling'], + requestFn: ( + isStaleRevalidation?: boolean, + attempt?: number, + ) => Promise< + FetchResponse + >, + pollingInterval?: RequestConfig['pollingInterval'], + shouldStopPolling?: RequestConfig['shouldStopPolling'], maxAttempts = 0, pollingDelay = 0, -): Promise { +): Promise> { + if (!pollingInterval) { + return requestFn(); + } + let pollingAttempt = 0; - let output: Output; + let output: FetchResponse; while (maxAttempts === 0 || pollingAttempt < maxAttempts) { if (pollingDelay > 0) { await delayInvocation(pollingDelay); } - output = await doRequestOnce(); + output = await requestFn(); pollingAttempt++; diff --git a/src/pubsub-manager.ts b/src/pubsub-manager.ts new file mode 100644 index 00000000..bbf0a0ec --- /dev/null +++ b/src/pubsub-manager.ts @@ -0,0 +1,82 @@ +/** + * Manages a set of listeners (subscribers) for arbitrary string keys, allowing cross-context or cross-component + * cache updates and synchronization. Provides functions to add, remove, and notify listeners, as well as a + * convenient subscribe/unsubscribe API. + * + * @template T - The type of the response object passed to listeners. + * + * @remarks + * - Listeners are grouped by a string key, which typically represents a cache key or resource identifier. + * - When `notifySubscribers` is called for a key, all listeners registered for that key are invoked with the provided response. + * - The `subscribe` function returns an unsubscribe function for convenient cleanup. + * + * @example + * ```ts + * const unsubscribe = subscribe('user:123', (response) => { + * // handle updated data + * }); + * // Later, to stop listening: + * unsubscribe(); + * ``` + */ + +import { noop } from './utils'; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type Listener = (response: T) => void; + +const listeners = new Map>(); + +function ensureListenerSet(key: string) { + if (!listeners.has(key)) { + listeners.set(key, new Set()); + } + + return listeners.get(key)!; +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export function addListener(key: string, fn: Listener): void { + ensureListenerSet(key).add(fn); +} + +export function removeListener(key: string, fn: Listener) { + const set = listeners.get(key); + + if (set) { + set.delete(fn); + + // If the set is empty, remove the key from the listeners map + if (set.size === 0) { + listeners.delete(key); + } + } +} + +export function notifySubscribers(key: string, response: T) { + const fns = listeners.get(key); + + if (fns) { + if (fns.size === 1) { + // If there's only one listener, call it directly + const fn = fns.values().next().value; + fn!(response); + } else { + fns.forEach((fn) => fn(response)); + } + } +} + +export function subscribe(key: string | null, fn: (response: T) => void) { + if (!key) { + // No op if no key is provided + return noop; + } + + addListener(key, fn); + + // Return an unsubscribe function + return () => { + removeListener(key, fn); + }; +} diff --git a/src/queue-manager.ts b/src/queue-manager.ts deleted file mode 100644 index ba9a154d..00000000 --- a/src/queue-manager.ts +++ /dev/null @@ -1,166 +0,0 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { ABORT_ERROR, TIMEOUT_ERROR } from './constants'; -import type { QueueItem } from './types/queue-manager'; - -/** - * Queue Manager is responsible for managing and controlling the flow of concurrent or sequential requests. It handles: - * - Request Queueing and Deduplication - * - Request Timeout Handling - * - Abort Controller Management and Request Cancellation - * - Concurrency Control and Locking - * - Request Lifecycle Management - */ -const queue: Map = new Map(); - -/** - * Adds a request to the queue if it's not already being processed within the dedupeTime interval. - * - * @param {string | null} key - Unique key for the request (e.g. cache key). - * @param {string} url - The request URL (for error messages/timeouts). - * @param {number} timeout - Timeout in milliseconds for the request. - * @param {number} dedupeTime - Deduplication time in milliseconds. - * @param {boolean} isCancellable - If true, then the previous request with same configuration should be aborted. - * @param {boolean} isTimeoutEnabled - Whether timeout is enabled. - * @returns {Promise} - A promise that resolves to an AbortController. - */ -export async function queueRequest( - key: string | null, - url: string, - timeout: number | undefined, - dedupeTime: number = 0, - isCancellable: boolean = false, - isTimeoutEnabled: boolean = true, -): Promise { - if (!key) { - return new AbortController(); - } - - const now = Date.now(); - const item = queue.get(key); - - if (item) { - const prevIsCancellable = item[3]; - const previousController = item[0]; - const timeoutId = item[1]; - - // If the request is already in the queue and within the dedupeTime, reuse the existing controller - if (!prevIsCancellable && now - item[2] < dedupeTime) { - return previousController; - } - - // If the request is too old, remove it and proceed to add a new one - // Abort previous request, if applicable, and continue as usual - if (prevIsCancellable) { - previousController.abort( - new DOMException('Aborted due to new request', ABORT_ERROR), - ); - } - - if (timeoutId !== null) { - clearTimeout(timeoutId); - } - - queue.delete(key); - } - - const controller = new AbortController(); - - const timeoutId = isTimeoutEnabled - ? setTimeout(() => { - const error = new DOMException( - `${url} aborted due to timeout`, - TIMEOUT_ERROR, - ); - - removeRequestFromQueue(key, error); - }, timeout) - : null; - - queue.set(key, [controller, timeoutId, now, isCancellable]); - - return controller; -} - -/** - * Removes a request from the queue and clears its timeout. - * - * @param key - Unique key for the request. - * @param {boolean} error - Error payload so to force the request to abort. - */ -export async function removeRequestFromQueue( - key: string | null, - error: DOMException | null | string = null, -): Promise { - // If the key is not in the queue, there's nothing to remove - if (!key) { - return; - } - - const item = queue.get(key); - - if (item) { - const controller = item[0]; - const timeoutId = item[1]; - - // If the request is not yet aborted, abort it with the provided error - if (error && !controller.signal.aborted) { - controller.abort(error); - } - - if (timeoutId !== null) { - clearTimeout(timeoutId); - } - - queue.delete(key); - } -} - -/** - * Gets the AbortController for a request key. - * - * @param key - Unique key for the request. - * @returns {AbortController | undefined} - The AbortController or undefined. - */ -export async function getController( - key: string, -): Promise { - const item = queue.get(key); - - return item?.[0]; -} - -/** - * Adds helpers for in-flight promise deduplication. - * - * @param key - Unique key for the request. - * @param promise - The promise to store. - */ -export function setInFlightPromise(key: string, promise: Promise) { - const item = queue.get(key); - if (item) { - // store the promise at index 4 - item[4] = promise; - - queue.set(key, item); - } -} - -/** - * Retrieves the in-flight promise for a request key if it exists and is within the dedupeTime interval. - * - * @param key - Unique key for the request. - * @param dedupeTime - Deduplication time in milliseconds. - * @returns {Promise | null} - The in-flight promise or null. - */ -export function getInFlightPromise( - key: string, - dedupeTime: number, -): Promise | null { - const item = queue.get(key); - - if (item && item[4] && Date.now() - item[2] < dedupeTime) { - return item[4]; - } - - return null; -} diff --git a/src/react/cache-ref.ts b/src/react/cache-ref.ts new file mode 100644 index 00000000..1dacf322 --- /dev/null +++ b/src/react/cache-ref.ts @@ -0,0 +1,93 @@ +/** + * @module cache-ref + * + * Provides reference counting utilities for cache management in React applications. + * + * This module maintains an internal reference count for cache keys, allowing for + * precise control over when cache entries should be deleted. It exports functions + * to increment and decrement reference counts, retrieve the current count, and clear + * all reference counts. When a reference count drops to zero and certain conditions + * are met, the corresponding cache entry is scheduled for deletion. + * + * @see deleteCache + */ + +import { addTimeout, abortRequest, deleteCache } from 'fetchff'; + +export const INFINITE_CACHE_TIME = -1; +export const DEFAULT_DEDUPE_TIME_MS = 2000; + +const refs = new Map(); + +export const incrementRef = (key: string | null) => { + if (key) { + refs.set(key, (refs.get(key) || 0) + 1); + } +}; + +export const decrementRef = ( + key: string | null, + cacheTime?: number, + dedupeTime?: number, + url?: string | null, +) => { + if (!key) { + return; + } + + const current = getRefCount(key); + + if (!current) { + return; + } + + const newCount = current - 1; + + // If the current reference count is less than 2, we can consider deleting the global cache entry + // The infinite cache time is a special case where we never delete the cache entry unless the reference count drops to zero. + // This allows for long-lived cache entries that are only deleted when explicitly no longer needed. + if (newCount <= 0) { + refs.delete(key); + + if (cacheTime && cacheTime === INFINITE_CACHE_TIME) { + // Delay to ensure all operations are complete before deletion + addTimeout( + 'r:' + key, + () => { + // Abort any ongoing requests associated with this cache key + abortRequest( + key, + new DOMException('Request to ' + url + ' aborted', 'AbortError'), + ); + + // Check if the reference count is still zero before deleting the cache as it might have been incremented again + // This is to ensure that if another increment happens during the timeout, we don't delete the cache prematurely + // This is particularly useful in scenarios where multiple components might be using the same cache + // entry and we want to avoid unnecessary cache deletions. + if (!getRefCount(key)) { + deleteCache(key, true); + } + }, + dedupeTime ?? DEFAULT_DEDUPE_TIME_MS, + ); + } + } else { + refs.set(key, newCount); + } +}; + +export const getRefCount = (key: string | null): number => { + if (!key) { + return 0; + } + + return refs.get(key) || 0; +}; + +export const getRefs = (): Map => { + return refs; +}; + +export const clearRefCache = () => { + refs.clear(); +}; diff --git a/src/react/index.ts b/src/react/index.ts new file mode 100644 index 00000000..6dbc946c --- /dev/null +++ b/src/react/index.ts @@ -0,0 +1,291 @@ +import { useCallback, useSyncExternalStore, useMemo, useRef } from 'react'; +import { + fetchf, + subscribe, + buildConfig, + generateCacheKey, + getCachedResponse, + getInFlightPromise, + getCache, +} from 'fetchff'; +import type { + DefaultParams, + DefaultPayload, + DefaultResponse, + DefaultUrlParams, + FetchResponse, + RequestConfig, +} from '..'; +import type { UseFetcherResult } from '../types/react-hooks'; + +import { + decrementRef, + DEFAULT_DEDUPE_TIME_MS, + getRefCount, + incrementRef, + INFINITE_CACHE_TIME, +} from './cache-ref'; + +// In React, we use a default stale time of 5 minutes (SWR) +const DEFAULT_STALE_TIME = 300; // 5 minutes + +// Pre-allocate objects to avoid GC pressure +const DEFAULT_RESULT = Object.freeze({ + data: null, + error: null, + isFetching: false, + mutate: () => Promise.resolve(null), + config: {}, + headers: {}, +}); + +const FETCHING_RESULT = Object.freeze({ + ...DEFAULT_RESULT, + isFetching: true, +}); + +const DEFAULT_REF = [null, {}, null] as [ + string | null, + RequestConfig, + string | null, +]; + +// RFC 7231: GET and HEAD are "safe methods" with no side effects +const SAFE_METHODS = new Set(['GET', 'HEAD', 'get', 'head']); + +/** + * High-performance React hook for fetching data with caching, deduplication, revalidation etc. + * + * @template ResponseData - The expected response data type. + * @template RequestBody - The request payload type. + * @template QueryParams - The query parameters type. + * @template PathParams - The URL path parameters type. + * + * @param {string|null} url - The endpoint URL to fetch data from. Pass null to skip fetching. + * If the URL is null, the hook will not perform any fetch operation. + * If the URL is an empty string, it will default to the base URL configured in fetchff. + * If the URL is a full URL, it will be used as is. + * @param {RequestConfig} [config={}] - fetchff and native fetch compatible configuration. + * + * @returns {UseFetcherResult} An object containing: + * - `data`: The fetched data or `null` if not yet available. + * - `error`: Any error encountered during fetching or `null`. + * - `isLoading`: Boolean indicating if the request is in progress. + * - `mutate`: Function to update the cached data and optionally trigger revalidation. + * + * @remarks + * - Designed for high performance: minimizes unnecessary re-renders and leverages fast cache key generation. + * - Integrates with a global cache and pub/sub system for efficient state updates across contexts. + * - Handles automatic revalidation, deduplication, retries, and cache management out of the box. + * + * @example + * ```tsx + * const { data, error, isLoading, mutate } = useFetcher('/api/data', { + * refetchOnFocus: true, + * cacheTime: 5, + * dedupeTime: 2000, + * cacheKey: (config) => `custom-cache-key-${config.url}`, + * }); + * ``` + */ +export function useFetcher< + ResponseData = DefaultResponse, + RequestBody = DefaultPayload, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, +>( + url: string | null, + config: RequestConfig< + ResponseData, + QueryParams, + PathParams, + RequestBody + > = {}, +): UseFetcherResult { + // Efficient cache key generation based on URL and request parameters. + // Optimized for speed: minimizes unnecessary function calls when possible + const cacheKey = useMemo( + () => (url === null ? null : generateCacheKey(buildConfig(url, config))), + [ + config.cacheKey, + url, + config.url, + config.method, + config.headers, + config.body, + config.params, + config.urlPathParams, + config.apiUrl, + config.baseURL, + config.withCredentials, + config.credentials, + ], + ); + const dedupeTime = config.dedupeTime ?? DEFAULT_DEDUPE_TIME_MS; + const cacheTime = config.cacheTime || INFINITE_CACHE_TIME; + const staleTime = config.staleTime ?? DEFAULT_STALE_TIME; + + // Determine if the fetch should be triggered immediately on mount + const shouldTriggerOnMount = + config.immediate ?? SAFE_METHODS.has(config.method || 'GET'); + + const currentValuesRef = useRef(DEFAULT_REF); + currentValuesRef.current = [url, config, cacheKey]; + + // Attempt to get the cached response immediately and if not available, return null + const getSnapshot = useCallback(() => { + const cached = getCache( + cacheKey, + ); + + // Only throw for Suspense if we're in 'reject' mode and have no data + if ( + config.strategy === 'reject' && + cacheKey && + (!cached || (!cached.data.data && !cached.data.error)) + ) { + const pendingPromise = getInFlightPromise(cacheKey, dedupeTime); + + if (pendingPromise) { + throw pendingPromise; + } + + // If no pending promise but we need to fetch, start fetch and throw the promise + if (!cached) { + const [currUrl, currConfig, currCacheKey] = currentValuesRef.current; + + if (currUrl) { + const fetchPromise = fetchf(currUrl, { + ...currConfig, + cacheKey: currCacheKey, + dedupeTime, + cacheTime, + staleTime, + strategy: 'softFail', + cacheErrors: true, + _isAutoKey: !currConfig.cacheKey, + }); + + throw fetchPromise; + } + } + } + + if (cached) { + return cached.data.isFetching && !config.keepPreviousData + ? (FETCHING_RESULT as unknown as FetchResponse< + ResponseData, + RequestBody, + QueryParams, + PathParams + >) + : cached.data; + } + + return (shouldTriggerOnMount + ? FETCHING_RESULT + : DEFAULT_RESULT) as unknown as FetchResponse< + ResponseData, + RequestBody, + QueryParams, + PathParams + >; + }, [cacheKey]); + + // Subscribe to cache updates for the specific cache key + const doSubscribe = useCallback( + (cb: () => void) => { + incrementRef(cacheKey); + + // When the component mounts, we want to fetch data if: + // 1. URL is provided + // 2. shouldTriggerOnMount is true (so the "immediate" isn't specified or is true) + // 3. There is no cached data + // 4. There is no error + // 5. There is no ongoing fetch operation + const shouldFetch = + shouldTriggerOnMount && url && cacheKey && getRefCount(cacheKey) === 1; // Check if no existing refs + + // Initial fetch logic + if (shouldFetch) { + // Stale-While-Revalidate Pattern: Check for both fresh and stale data + const cached = getCachedResponse(cacheKey, cacheTime, config); + + if (!cached) { + refetch(false); + } + } + + const unsubscribe = subscribe(cacheKey, cb); + + return () => { + decrementRef(cacheKey, cacheTime, dedupeTime, url); + unsubscribe(); + }; + }, + [cacheKey, shouldTriggerOnMount, url, dedupeTime, cacheTime], + ); + + const state = useSyncExternalStore< + FetchResponse + >(doSubscribe, getSnapshot, getSnapshot); + + const refetch = useCallback( + async (forceRefresh = true) => { + const [currUrl, currConfig, currCacheKey] = currentValuesRef.current; + + if (!currUrl) { + return Promise.resolve(null); + } + + // Truthy check for forceRefresh to ensure it's a boolean. It is useful in onClick handlers so to avoid additional annonymous function calls. + const shouldRefresh = !!forceRefresh; + + // Fast path: check cache first if not forcing refresh + if (!shouldRefresh && currCacheKey) { + const cached = getCachedResponse(currCacheKey, cacheTime, currConfig); + + if (cached) { + return Promise.resolve(cached); + } + } + + // When manual refetch is triggered, we want to ensure that the cache is busted + // This can be disabled by passing `refetch(false)` + const cacheBuster = shouldRefresh ? () => true : currConfig.cacheBuster; + + return fetchf(currUrl, { + ...currConfig, + cacheKey: currCacheKey, + dedupeTime, + cacheTime, + staleTime, + cacheBuster, + // Ensure that errors are handled gracefully and not thrown by default + strategy: 'softFail', + cacheErrors: true, + _isAutoKey: !currConfig.cacheKey, + }); + }, + [cacheTime, dedupeTime], + ); + + const data = state.data; + const isUnresolved = !data && !state.error; + const isFetching = state.isFetching; + const isLoading = + !!url && (isFetching || (isUnresolved && shouldTriggerOnMount)); + + // Consumers always destructure the return value and use the fields directly, so + // memoizing the object doesn't change rerender behavior nor improve any performance here + return { + data, + error: state.error, + config: state.config, + headers: state.headers, + isFetching, + isLoading, + mutate: state.mutate, + refetch, + }; +} diff --git a/src/request-handler.ts b/src/request-handler.ts index 1b277642..a4965f1f 100644 --- a/src/request-handler.ts +++ b/src/request-handler.ts @@ -1,423 +1,327 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import type { DefaultResponse, - RequestHandlerConfig, RequestConfig, - RetryOptions, FetchResponse, - RequestHandlerReturnType, - CreatedCustomFetcherInstance, } from './types/request-handler'; import type { DefaultParams, DefaultPayload, DefaultUrlParams, } from './types/api-handler'; -import { applyInterceptor } from './interceptor-manager'; +import { applyInterceptors } from './interceptor-manager'; import { ResponseError } from './errors/response-error'; -import { delayInvocation, sanitizeObject } from './utils'; +import { isObject } from './utils'; import { - queueRequest, - removeRequestFromQueue, + markInFlight, setInFlightPromise, getInFlightPromise, -} from './queue-manager'; -import { ABORT_ERROR, CANCELLED_ERROR } from './constants'; -import { prepareResponse, parseResponseData } from './response-parser'; +} from './inflight-manager'; +import { parseResponseData, prepareResponse } from './response-parser'; import { generateCacheKey, getCachedResponse, setCache } from './cache-manager'; -import { buildConfig, defaultConfig, mergeConfig } from './config-handler'; -import { getRetryAfterMs } from './retry-handler'; +import { withRetry } from './retry-handler'; import { withPolling } from './polling-handler'; +import { notifySubscribers } from './pubsub-manager'; +import { addRevalidator } from './revalidator-manager'; +import { enhanceError, withErrorHandling } from './error-handler'; +import { FUNCTION } from './constants'; +import { buildConfig } from './config-handler'; + +const inFlightResponse = { + isFetching: true, +}; /** - * Create Request Handler + * Sends an HTTP request to the specified URL using the provided configuration and returns a typed response. + * + * @typeParam ResponseData - The expected shape of the response data. Defaults to `DefaultResponse`. + * @typeParam RequestBody - The type of the request payload/body. Defaults to `DefaultPayload`. + * @typeParam QueryParams - The type of the query parameters. Defaults to `DefaultParams`. + * @typeParam PathParams - The type of the path parameters. Defaults to `DefaultUrlParams`. + * + * @param url - The endpoint URL to which the request will be sent. + * @param config - Optional configuration object for the request, including headers, method, body, query, and path parameters. * - * @param {RequestHandlerConfig} config - Configuration object for the request handler - * @returns {Object} An object with methods for handling requests + * @returns A promise that resolves to a `FetchResponse` containing the typed response data and request metadata. + * + * @example + * ```typescript + * const { data } = await fetchf('/api/user', { method: 'GET' }); + * console.log(data); + * ``` */ -export function createRequestHandler( - config: RequestHandlerConfig | null, -): RequestHandlerReturnType { - const sanitizedConfig = config ? sanitizeObject(config) : {}; - const handlerConfig: RequestHandlerConfig = { - ...defaultConfig, - ...sanitizedConfig, - }; - - mergeConfig('retry', handlerConfig, defaultConfig, sanitizedConfig); - mergeConfig('headers', handlerConfig, defaultConfig, sanitizedConfig); - - /** - * Immediately create instance of custom fetcher if it is defined - */ - const requestInstance = sanitizedConfig.fetcher?.create?.(handlerConfig); - - /** - * Get Provider Instance - * - * @returns {CreatedCustomFetcherInstance | null} Provider's instance - */ - const getInstance = (): CreatedCustomFetcherInstance | null => { - return requestInstance || null; - }; - - /** - * Request function to make HTTP requests with the provided URL and configuration. - * - * @param {string} url - Request URL - * @param {RequestConfig} reqConfig - Request config passed when making the request - * @throws {ResponseError} If the request fails or is cancelled - * @returns {Promise>} Response Data - */ - const request = async < - ResponseData = DefaultResponse, - QueryParams = DefaultParams, - PathParams = DefaultUrlParams, - RequestBody = DefaultPayload, - >( - url: string, - reqConfig: RequestConfig< - ResponseData, - QueryParams, - PathParams, - RequestBody - > | null = null, - ): Promise< - FetchResponse - > => { - const _reqConfig = reqConfig ? sanitizeObject(reqConfig) : {}; - - // Ensure immutability - const mergedConfig = { - ...handlerConfig, - ..._reqConfig, - }; - - mergeConfig('retry', mergedConfig, handlerConfig, _reqConfig); - mergeConfig('headers', mergedConfig, handlerConfig, _reqConfig); +export async function fetchf< + ResponseData = DefaultResponse, + RequestBody = DefaultPayload, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, +>( + url: string, + reqConfig: RequestConfig< + ResponseData, + QueryParams, + PathParams, + RequestBody + > | null = null, +): Promise> { + const fetcherConfig = buildConfig< + ResponseData, + RequestBody, + QueryParams, + PathParams + >(url, reqConfig); + + const { + timeout, + cancellable, + cacheKey, + dedupeTime, + cacheTime, + staleTime, + refetchOnFocus, + refetchOnReconnect, + pollingInterval = 0, + } = fetcherConfig; + const isCacheEnabled = cacheTime !== undefined || staleTime !== undefined; + + const needsCacheKey = !!( + cacheKey || + timeout || + dedupeTime || + isCacheEnabled || + cancellable || + refetchOnFocus || + refetchOnReconnect + ); + + let _cacheKey: string | null = null; + + // Generate cache key if required + if (needsCacheKey) { + _cacheKey = generateCacheKey(fetcherConfig); + } - let response: FetchResponse< + // Cache handling logic + if (_cacheKey && isCacheEnabled) { + const cached = getCachedResponse< ResponseData, RequestBody, QueryParams, PathParams - > | null = null; - const fetcherConfig = buildConfig(url, mergedConfig); + >(_cacheKey, cacheTime, fetcherConfig); - const { - timeout, - cancellable, - dedupeTime, - cacheTime, - cacheKey, - pollingInterval = 0, - } = mergedConfig; - - // Prevent performance overhead of cache access - let _cacheKey: string | null = null; - - // Generate cache key if required - if (cacheTime || dedupeTime || cancellable || timeout) { - _cacheKey = cacheKey - ? cacheKey(fetcherConfig) - : generateCacheKey(fetcherConfig); + if (cached) { + return cached; } + } - // Cache handling logic - if (cacheTime) { - const cached = getCachedResponse< - ResponseData, - RequestBody, - QueryParams, - PathParams - >(_cacheKey, cacheTime, mergedConfig.cacheBuster, fetcherConfig); - - if (cached) { - return cached; - } - } + // Deduplication logic + if (_cacheKey && dedupeTime) { + const inflight = getInFlightPromise< + FetchResponse + >(_cacheKey, dedupeTime); - // Deduplication logic - if (_cacheKey && dedupeTime) { - const inflight = getInFlightPromise(_cacheKey, dedupeTime); - - if (inflight) { - return (await inflight) as FetchResponse< - ResponseData, - RequestBody, - QueryParams, - PathParams - >; - } + if (inflight) { + return inflight; } + } - // The actual request logic as a function (one poll attempt, with retries) - const doRequestOnce = async () => { - const { - retries = 0, - delay, - backoff, - retryOn, - shouldRetry, - maxDelay, - resetTimeout, - } = mergedConfig.retry as RetryOptions< - ResponseData, - QueryParams, - PathParams, - RequestBody - >; - let attempt = 0; - let waitTime: number = delay || 0; - const _retries = retries > 0 ? retries : 0; - - while (attempt <= _retries) { - try { - // Add the request to the queue. Make sure to handle deduplication, cancellation, timeouts in accordance to retry settings - const controller = await queueRequest( + const retryConfig = fetcherConfig.retry || {}; + const { retries = 0, resetTimeout } = retryConfig; + + // The actual request logic as a function (one poll attempt, with retries) + const doRequestOnce = async (isStaleRevalidation = false, attempt = 0) => { + // If cache key is specified, we will handle optimistic updates + // and mark the request as in-flight, so to catch "fetching" state. + // This is useful for Optimistic UI updates (e.g., showing loading spinners). + if (!attempt) { + if (_cacheKey && !isStaleRevalidation) { + if (staleTime) { + const existingCache = getCachedResponse( _cacheKey, - fetcherConfig.url as string, - timeout, - dedupeTime, - cancellable, - // Reset timeouts by default or when retries are ON - !!(timeout && (!_retries || resetTimeout)), + cacheTime, + fetcherConfig, ); - // Shallow copy to ensure basic idempotency - // Note that the refrence of the main object does not change here so it is safe in context of queue management and interceptors - const requestConfig: RequestConfig = { - signal: controller.signal, - ...fetcherConfig, - }; - - // Local interceptors - await applyInterceptor(requestConfig, _reqConfig.onRequest); - - // Global interceptors - await applyInterceptor(requestConfig, handlerConfig.onRequest); - - response = requestInstance?.request - ? await requestInstance.request(requestConfig) - : ((await fetch( - requestConfig.url as string, - requestConfig as RequestInit, - )) as unknown as FetchResponse< - ResponseData, - RequestBody, - QueryParams, - PathParams - >); - - // Add more information to response object - if (response instanceof Response) { - response.config = requestConfig; - response.data = await parseResponseData(response); - - // Check if the response status is not outside the range 200-299 and if so, output error - if (!response.ok) { - throw new ResponseError( - `${requestConfig.method} to ${requestConfig.url} failed! Status: ${response.status || null}`, - requestConfig, - response, - ); - } + // Don't notify subscribers when cache exists + // Let them continue showing stale data during background revalidation + if (!existingCache) { + setCache(_cacheKey, inFlightResponse, cacheTime, staleTime); + notifySubscribers(_cacheKey, inFlightResponse); } + } else { + notifySubscribers(_cacheKey, inFlightResponse); + } + } - // Local interceptors - await applyInterceptor(response, _reqConfig.onResponse); - - // Global interceptors - await applyInterceptor(response, handlerConfig.onResponse); - - removeRequestFromQueue(_cacheKey); + // Attach cache key so that it can be reused in interceptors or in the final response + fetcherConfig.cacheKey = _cacheKey; + } - const output = prepareResponse< - ResponseData, - QueryParams, - PathParams, - RequestBody - >(response, requestConfig); + const url = fetcherConfig.url as string; - // Retry on response logic - if ( - shouldRetry && - attempt < _retries && - (await shouldRetry(output, attempt)) - ) { - logger( - mergedConfig, - `Attempt ${attempt + 1} failed response data check. Retry in ${waitTime}ms.`, - ); + // Add the request to the queue. Make sure to handle deduplication, cancellation, timeouts in accordance to retry settings + const controller = markInFlight( + _cacheKey, + url, + timeout, + dedupeTime || 0, + !!cancellable, + // Enable timeout either by default or when retries & resetTimeout are enabled + !!(timeout && (!attempt || resetTimeout)), + ); - await delayInvocation(waitTime); + // Do not create a shallow copy to maintain idempotency here. + // This ensures the original object is mutated by interceptors whenever needed, including retry logic. + const requestConfig = fetcherConfig; - waitTime *= backoff || 1; - waitTime = Math.min(waitTime, maxDelay || waitTime); - attempt++; + requestConfig.signal = controller.signal; - continue; // Retry the request - } + let output: FetchResponse< + ResponseData, + RequestBody, + QueryParams, + PathParams + >; + let response: FetchResponse< + ResponseData, + RequestBody, + QueryParams, + PathParams + > | null = null; - if ( - cacheTime && - _cacheKey && - (!requestConfig.skipCache || - !requestConfig.skipCache(output, requestConfig)) - ) { - setCache(_cacheKey, output); - } + try { + if (fetcherConfig.onRequest) { + await applyInterceptors(fetcherConfig.onRequest, requestConfig); + } - return output; - } catch (err) { - const error = err as ResponseError< - ResponseData, - QueryParams, - PathParams, - RequestBody - >; - - // Append additional information to Network, CORS or any other fetch() errors - error.status = error?.status || response?.status || 0; - error.statusText = error?.statusText || response?.statusText || ''; - error.config = fetcherConfig; - error.request = fetcherConfig; - error.response = response; - - // Prepare Extended Response - const output = prepareResponse< - ResponseData, - QueryParams, - PathParams, - RequestBody - >(response, fetcherConfig, error); - - if ( - // We check retries provided regardless of the shouldRetry being provided so to avoid infinite loops. - // It is a fail-safe so to prevent excessive retry attempts even if custom retry logic suggests a retry. - attempt === _retries || // Stop if the maximum retries have been reached - !retryOn?.includes(error.status) || // Check if the error status is retryable - !shouldRetry || - !(await shouldRetry(output, attempt)) // If shouldRetry is defined, evaluate it - ) { - if (!isRequestCancelled(error as ResponseError)) { - logger(mergedConfig, 'FETCH ERROR', error as ResponseError); - } - - // Local interceptors - await applyInterceptor(error, _reqConfig.onError); - - // Global interceptors - await applyInterceptor(error, handlerConfig.onError); - - // Remove the request from the queue - removeRequestFromQueue(_cacheKey); - - // Timeouts and request cancellations using AbortController do not throw any errors unless rejectCancelled is true. - // Only handle the error if the request was not cancelled, or if it was cancelled and rejectCancelled is true. - const isCancelled = isRequestCancelled(error as ResponseError); - const shouldHandleError = - !isCancelled || mergedConfig.rejectCancelled; - - if (shouldHandleError) { - const errorHandlingStrategy = mergedConfig.strategy; - - // Reject the promise - if (errorHandlingStrategy === 'reject') { - return Promise.reject(error); - } // Hang the promise - else if (errorHandlingStrategy === 'silent') { - await new Promise(() => null); - } - } - - return output; - } + // Custom fetcher + const fn = fetcherConfig.fetcher; - // If the error status is 429 (Too Many Requests), handle rate limiting - if (error.status === 429) { - // Try to extract the "Retry-After" value from the response headers - const retryAfterMs = getRetryAfterMs(output); + response = (fn + ? await fn( + url, + requestConfig, + ) + : await fetch( + url, + requestConfig as RequestInit, + )) as unknown as FetchResponse< + ResponseData, + RequestBody, + QueryParams, + PathParams + >; - // If a valid retry-after value is found, override the wait time before next retry - if (retryAfterMs !== null) { - waitTime = retryAfterMs; - } + if (isObject(response)) { + // Case 1: Native Response instance + if (typeof Response === FUNCTION && response instanceof Response) { + response.data = await parseResponseData(response); + } else if (fn) { + // Case 2: Custom fetcher that returns a response object + if (!('data' in response && 'body' in response)) { + // Case 3: Raw data, wrap it + response = { data: response } as unknown as FetchResponse< + ResponseData, + RequestBody, + QueryParams, + PathParams + >; } + } - logger( - mergedConfig, - `Attempt ${attempt + 1} failed. Retry in ${waitTime}ms.`, + // Attach config and data to the response + // This is useful for custom fetchers that do not return a Response instance + // and for interceptors that may need to access the request config + response.config = requestConfig; + + // Check if the response status is not outside the range 200-299 and if so, output error + // This is the pattern for fetch responses as per spec, but custom fetchers may not follow it so we check for `ok` property + if (response.ok !== undefined && !response.ok) { + throw new ResponseError( + `${requestConfig.method} to ${url} failed! Status: ${response.status || null}`, + requestConfig, + response, ); - - await delayInvocation(waitTime); - - waitTime *= backoff || 1; - waitTime = Math.min(waitTime, maxDelay || waitTime); - attempt++; } } - return prepareResponse< + output = prepareResponse< ResponseData, + RequestBody, QueryParams, - PathParams, - RequestBody - >(response, fetcherConfig); - }; - - // If polling is enabled, use withPolling to handle the request - const doRequestPromise = - pollingInterval > 0 - ? withPolling< - FetchResponse - >( - doRequestOnce, - pollingInterval, - mergedConfig.shouldStopPolling, - mergedConfig.maxPollingAttempts, - mergedConfig.pollingDelay, - ) - : doRequestOnce(); + PathParams + >(response, requestConfig); - // If deduplication is enabled, store the in-flight promise immediately - if (_cacheKey && dedupeTime) { - setInFlightPromise(_cacheKey, doRequestPromise); - } + const onResponse = fetcherConfig.onResponse; - return doRequestPromise; - }; + if (onResponse) { + await applyInterceptors(onResponse, output); + } + } catch (_error) { + const error = _error as ResponseError< + ResponseData, + RequestBody, + QueryParams, + PathParams + >; + + // Append additional information to Network, CORS or any other fetch() errors + enhanceError( + error, + response, + requestConfig, + ); - return { - getInstance, - config: handlerConfig, - request, + // Prepare Extended Response + output = prepareResponse< + ResponseData, + RequestBody, + QueryParams, + PathParams + >(response, requestConfig, error); + } + + return output; }; -} -/** - * Output error response depending on chosen strategy - * - * @param {ResponseError} error Error instance - * @returns {boolean} True if request is aborted - */ -const isRequestCancelled = (error: ResponseError): boolean => { - return error.name === ABORT_ERROR || error.name === CANCELLED_ERROR; -}; + // Inline and minimize function wrappers for performance + const baseRequest = + retries > 0 ? () => withRetry(doRequestOnce, retryConfig) : doRequestOnce; + + const requestWithErrorHandling = (isStaleRevalidation = false) => + withErrorHandling( + isStaleRevalidation, + baseRequest, + fetcherConfig, + ); + + // Avoid unnecessary function wrapping if polling is not enabled + const doRequestPromise = pollingInterval + ? withPolling( + requestWithErrorHandling, + pollingInterval, + fetcherConfig.shouldStopPolling, + fetcherConfig.maxPollingAttempts, + fetcherConfig.pollingDelay, + ) + : requestWithErrorHandling(); + + // If deduplication is enabled, store the in-flight promise immediately + if (_cacheKey) { + if (dedupeTime) { + setInFlightPromise(_cacheKey, doRequestPromise); + } -/** - * Logs messages or errors using the configured logger's `warn` method. - * - * @param {RequestConfig} reqConfig - Request config passed when making the request - * @param {...(string | ResponseError)} args - Messages or errors to log. - */ -const logger = ( - reqConfig: RequestConfig, - ...args: (string | ResponseError)[] -): void => { - const logger = reqConfig.logger; - - if (logger && logger.warn) { - logger.warn(...args); + addRevalidator( + _cacheKey, + requestWithErrorHandling, + undefined, + staleTime, + requestWithErrorHandling, + !!refetchOnFocus, + !!refetchOnReconnect, + ); } -}; + + return doRequestPromise; +} diff --git a/src/response-parser.ts b/src/response-parser.ts index 8dd8e3ee..d7513191 100644 --- a/src/response-parser.ts +++ b/src/response-parser.ts @@ -1,8 +1,10 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { mutate } from './cache-manager'; import { APPLICATION_CONTENT_TYPE, APPLICATION_JSON, CONTENT_TYPE, + FUNCTION, OBJECT, } from './constants'; import { @@ -14,7 +16,7 @@ import { DefaultUrlParams, DefaultPayload, } from './types'; -import { flattenData, processHeaders } from './utils'; +import { flattenData, isObject, processHeaders } from './utils'; /** * Parses the response data based on the Content-Type header. @@ -22,11 +24,16 @@ import { flattenData, processHeaders } from './utils'; * @param response - The Response object to parse. * @returns A Promise that resolves to the parsed data. */ -export async function parseResponseData( - response: FetchResponse, +export async function parseResponseData< + ResponseData = DefaultResponse, + RequestBody = DefaultPayload, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, +>( + response: FetchResponse, ): Promise { // Bail early for HEAD requests or status codes, or any requests that never have a body - if (!response?.body) { + if (!response || !response.body) { return null; } @@ -83,15 +90,15 @@ export async function parseResponseData( * Prepare response object with additional information. * * @param Response. It may be "null" in case of request being aborted. - * @param {RequestConfig} requestConfig - Request config + * @param {RequestConfig} config - Request config * @param error - whether the response is erroneous - * @returns {FetchResponse} Response data + * @returns {FetchResponse} Response data */ export const prepareResponse = < ResponseData = DefaultResponse, + RequestBody = DefaultPayload, QueryParams = DefaultParams, PathParams = DefaultUrlParams, - RequestBody = DefaultPayload, >( response: FetchResponse< ResponseData, @@ -99,20 +106,22 @@ export const prepareResponse = < QueryParams, PathParams > | null, - requestConfig: RequestConfig< - ResponseData, - QueryParams, - PathParams, - RequestBody - >, + config: RequestConfig, error: ResponseError< ResponseData, + RequestBody, QueryParams, - PathParams, - RequestBody + PathParams > | null = null, ): FetchResponse => { - const defaultResponse = requestConfig.defaultResponse ?? null; + const defaultResponse = config.defaultResponse; + const cacheKey = config.cacheKey; + const mutatator = mutate.bind(null, cacheKey as string) as FetchResponse< + ResponseData, + RequestBody, + QueryParams, + PathParams + >['mutate']; // This may happen when request is cancelled. if (!response) { @@ -120,9 +129,11 @@ export const prepareResponse = < ok: false, // Enhance the response with extra information error, - data: defaultResponse, + data: defaultResponse ?? null, headers: null, - config: requestConfig, + config, + mutate: mutatator, + isFetching: false, } as unknown as FetchResponse< ResponseData, RequestBody, @@ -131,50 +142,69 @@ export const prepareResponse = < >; } + const isNativeResponse = + typeof Response === FUNCTION && response instanceof Response; + let data = response.data; // Set the default response if the provided data is an empty object if ( - data === undefined || - data === null || - (typeof data === OBJECT && Object.keys(data).length === 0) + defaultResponse !== undefined && + (data === undefined || + data === null || + (typeof data === OBJECT && Object.keys(data).length === 0)) ) { - data = defaultResponse; + response.data = data = defaultResponse; } - if (requestConfig.flattenResponse) { - response.data = flattenData(data); + if (config.flattenResponse) { + response.data = data = flattenData(data); } - // If it's a custom fetcher, and it does not return any Response instance, it may have its own internal handler - if (!(response instanceof Response)) { - return response; + if (config.select) { + response.data = data = config.select(data); } + const headers = processHeaders(response.headers); + // Native fetch Response extended by extra information - return { - body: response.body, - bodyUsed: response.bodyUsed, - ok: response.ok, - redirected: response.redirected, - type: response.type, - url: response.url, - status: response.status, - statusText: response.statusText, - - // Convert methods to use arrow functions to preserve correct return types - blob: () => response.blob(), - json: () => response.json(), - text: () => response.text(), - clone: () => response.clone(), - arrayBuffer: () => response.arrayBuffer(), - formData: () => response.formData(), - bytes: () => response.bytes(), - - // Enhance the response with extra information - error, - data, - headers: processHeaders(response.headers), - config: requestConfig, - }; + if (isNativeResponse) { + return { + body: response.body, + bodyUsed: response.bodyUsed, + ok: response.ok, + redirected: response.redirected, + type: response.type, + url: response.url, + status: response.status, + statusText: response.statusText, + + // Convert methods to use arrow functions to preserve correct return types + blob: () => response.blob(), + json: () => response.json(), + text: () => response.text(), + clone: () => response.clone(), + arrayBuffer: () => response.arrayBuffer(), + formData: () => response.formData(), + bytes: () => response.bytes(), + + // Enhance the response with extra information + error, + data, + headers, + config, + mutate: mutatator, + isFetching: false, + }; + } + + // If it's a custom fetcher, and it does not return any Response instance, it may have its own internal handler + if (isObject(response)) { + response.error = error; + response.headers = headers; + response.isFetching = false; + response.mutate = mutatator; + } + + return response; }; diff --git a/src/retry-handler.ts b/src/retry-handler.ts index bf2ec15a..c6b3096b 100644 --- a/src/retry-handler.ts +++ b/src/retry-handler.ts @@ -1,4 +1,7 @@ -import type { FetchResponse } from './types'; +import { applyInterceptors } from './interceptor-manager'; +import type { FetchResponse, RetryConfig, RetryFunction } from './types'; +import { delayInvocation, timeNow } from './utils'; +import { generateCacheKey } from './cache-manager'; /** * Calculates the number of milliseconds to wait before retrying a request, @@ -31,10 +34,186 @@ export function getRetryAfterMs( const date = new Date(retryAfter); if (!isNaN(date.getTime())) { - const ms = date.getTime() - Date.now(); + const ms = date.getTime() - timeNow(); return ms > 0 ? ms : 0; } return null; } + +/** + * Executes a request function with retry logic according to the provided configuration. + * + * The function attempts the request up to the specified number of retries, applying delay and backoff strategies. + * Retries can be triggered based on response status codes, custom logic, or the presence of a `Retry-After` header. + * Optionally, an `onRetry` interceptor can be invoked before each retry attempt. + * + * @typeParam ResponseData - The type of the response data. + * @typeParam RequestBody - The type of the request body. + * @typeParam QueryParams - The type of the query parameters. + * @typeParam PathParams - The type of the path parameters. + * @param requestFn - The function that performs the request. Receives `isStaleRevalidation` and `attempt` as arguments. + * @param config - The retry configuration, including retry count, delay, backoff, retry conditions, and hooks. + * @returns A promise resolving to the fetch response, or rejecting if all retries are exhausted. + * @throws Error if the maximum number of retries is exceeded or a non-retriable error occurs. + */ +export async function withRetry< + ResponseData, + RequestBody, + QueryParams, + PathParams, +>( + requestFn: ( + isStaleRevalidation: boolean, + attempt: number, + ) => Promise< + FetchResponse + >, + config: RetryConfig, +): Promise> { + const { + retries = 0, + delay = 0, + backoff = 1, + maxDelay, + retryOn = [], + shouldRetry, + } = config; + + let attempt = 0; + let waitTime = delay; + const maxRetries = retries > 0 ? retries : 0; + let output: FetchResponse; + + while (attempt <= maxRetries) { + // Subsequent attempts will have output defined, but the first attempt may not. + // Let's apply onRetry interceptor and regenerate cache key if ot really changes. + if (attempt > 0 && output!) { + const cfg = output.config; + const onRetry = cfg.onRetry; + + if (onRetry) { + await applyInterceptors(onRetry, output, attempt); + + // If the key was automatically generated, we need to regenerate it as config may change. + // We don't detect whether config changed for performance reasons. + if (cfg._isAutoKey) { + cfg._prevKey = cfg.cacheKey as string; + cfg.cacheKey = generateCacheKey(cfg, false); + } + } + } + + output = await requestFn(true, attempt); // isStaleRevalidation=false, isFirstAttempt=attempt===0 + const error = output.error; + + // Check if we should retry based on successful response + if (!error) { + if (shouldRetry && attempt < maxRetries) { + const shouldRetryResult = await shouldRetry(output, attempt); + + if (shouldRetryResult) { + await delayInvocation(waitTime); + waitTime *= backoff || 1; + waitTime = Math.min(waitTime, maxDelay || waitTime); + attempt++; + continue; + } + } + + break; + } + + // Determine if we should stop retrying + const shouldStopRetrying = await getShouldStopRetrying( + output, + attempt, + maxRetries, + shouldRetry, + retryOn, + ); + + if (shouldStopRetrying) { + break; + } + + // If we should not stop retrying, continue to the next attempt + // If the error status is 429 (Too Many Requests), handle rate limiting + if (error.status === 429) { + // Try to extract the "Retry-After" value from the response headers + const retryAfterMs = getRetryAfterMs(output); + + // If a valid retry-after value is found, override the wait time before next retry + if (retryAfterMs !== null) { + waitTime = retryAfterMs; + } + } + + await delayInvocation(waitTime); + waitTime *= backoff || 1; + waitTime = Math.min(waitTime, maxDelay || waitTime); + attempt++; + } + + return output!; +} + +/** + * Determines whether to stop retrying based on the error, current attempt count, and retry configuration. + * + * This function checks: + * - If the maximum number of retries has been reached. + * - If a custom `shouldRetry` callback is provided, its result is used to decide. + * - If no custom logic is provided, falls back to checking if the error status is included in the `retryOn` list. + * + * @typeParam ResponseData - The type of the response data. + * @typeParam RequestBody - The type of the request body. + * @typeParam QueryParams - The type of the query parameters. + * @typeParam PathParams - The type of the path parameters. + * @param output - The response object containing the error and request configuration. + * @param attempt - The current retry attempt number. + * @param maxRetries - The maximum number of retry attempts allowed. + * @param shouldRetry - Optional custom function to determine if a retry should occur. + * @param retryOn - Optional list of HTTP status codes that should trigger a retry. + * @returns A promise resolving to `true` if retrying should stop, or `false` to continue retrying. + */ +export async function getShouldStopRetrying< + ResponseData, + RequestBody, + QueryParams, + PathParams, +>( + output: FetchResponse, + attempt: number, + maxRetries: number, + shouldRetry?: RetryFunction< + ResponseData, + RequestBody, + QueryParams, + PathParams + > | null, + retryOn: number[] = [], +): Promise { + // Safety first: always respect max retries + // We check retries provided regardless of the shouldRetry being provided so to avoid infinite loops. + // It is a fail-safe so to prevent excessive retry attempts even if custom retry logic suggests a retry. + if (attempt === maxRetries) { + return true; + } + + let customDecision: boolean | null = null; + + // Get custom decision if shouldRetry is provided + if (shouldRetry) { + const result = await shouldRetry(output, attempt); + customDecision = result; + + // Decision cascade: + if (customDecision !== null) { + return !customDecision; + } + } + + return !(retryOn || []).includes(output.error?.status ?? 0); +} diff --git a/src/revalidator-manager.ts b/src/revalidator-manager.ts new file mode 100644 index 00000000..18781647 --- /dev/null +++ b/src/revalidator-manager.ts @@ -0,0 +1,250 @@ +/** + * @module revalidator-manager + * + * Provides utilities for managing cache revalidation functions, including: + * - Registering and unregistering revalidators for specific cache keys. + * - Triggering revalidation for a given key. + * - Enabling or disabling automatic revalidation on window focus and if user comes back online for specific keys. + * - Attaching and removing global focus and online event handlers to trigger revalidation. + * + * Revalidators are functions that can be registered to revalidate cache entries when needed. + * They are typically used to refresh data in the cache when the window gains focus or when specific actions occur. + * @performance O(1) lookup by key makes it blazing fast to register, unregister, and revalidate cache entries. + * - Designed for high performance: minimizes unnecessary re-renders and leverages fast cache key generation. + * - Integrates with a global cache and pub/sub system for efficient state updates across contexts. + * - Handles automatic revalidation, deduplication, retries, and cache management out of the box. + * @remarks + * - Designed to be used in various environments (Deno, Node.js, Bun, Browser, etc.) to ensure cache consistency and freshness. + */ +import { addTimeout, removeTimeout } from './timeout-wheel'; +import { FetchResponse } from './types'; +import { isBrowser, noop, timeNow } from './utils'; + +export type RevalidatorFn = ( + isStaleRevalidation?: boolean, +) => Promise; + +type EventType = 'focus' | 'online'; + +type RevalidatorEntry = [ + RevalidatorFn, // main revalidator + number, // lastUsed + number, // ttl + number?, // staleTime + RevalidatorFn?, // bgRevalidator + boolean?, // refetchOnFocus + boolean?, // refetchOnReconnect +]; + +const DEFAULT_TTL = 3 * 60 * 1000; // Default TTL of 3 minutes +const revalidators = new Map(); + +/** + * Stores global event handlers for cache revalidation events (e.g., focus, online). + * This avoids attaching multiple event listeners by maintaining a single handler per event type. + * Event handlers are registered as needed when revalidators are registered with the corresponding flags. + * @remarks + * - Improves performance by reducing the number of event listeners. + * - Enables efficient O(1) lookup and management of event handlers for revalidation. + */ +const eventHandlers = new Map void>(); + +/** + * Triggers revalidation for all registered entries based on the given event type. + * For example, if it's a 'focus' event, it will revalidate entries that have the `refetchOnFocus` flag set. + * Updates the timestamp and invokes the revalidator function for each applicable entry. + * + * @param type - The type of event that caused the revalidation (e.g., 'focus' or 'online'). + * @param isStaleRevalidation - If `true`, uses background revalidator and doesn't mark as in-flight. + */ +export function revalidateAll( + type: EventType, + isStaleRevalidation: boolean = true, +) { + const flagIndex = type === 'focus' ? 5 : 6; + const now = timeNow(); + + revalidators.forEach((entry) => { + if (!entry[flagIndex]) { + return; + } + + entry[1] = now; + + // If it's a stale revalidation, use the background revalidator function + const revalidator = isStaleRevalidation ? entry[4] : entry[0]; + + if (revalidator) { + Promise.resolve(revalidator(isStaleRevalidation)).catch(noop); + } + }); +} + +/** + * Revalidates an entry by executing the registered revalidation function. + * + * @param key The unique identifier for the cache entry to revalidate. If `null`, no revalidation occurs. + * @param isStaleRevalidation - If `true`, it does not mark revalidated requests as in-flight. + * @returns A promise that resolves to the result of the revalidator function, or + * `null` if no key or revalidator is found, or a `FetchResponse` if applicable. + */ +export async function revalidate( + key: string | null, + isStaleRevalidation: boolean = false, +): Promise { + // If no key is provided, no revalidation occurs + if (!key) { + return null; + } + + const entry = revalidators.get(key); + + if (entry) { + // Update only the lastUsed timestamp without resetting the whole array + entry[1] = timeNow(); + + const revalidator = isStaleRevalidation ? entry[4] : entry[0]; + + // If no revalidator function is registered, return null + if (revalidator) { + return await revalidator(isStaleRevalidation); + } + } + + // If no revalidator is registered for the key, return null + return null; +} + +/** + * Removes all revalidators associated with the specified event type. + * + * @param type - The event type whose revalidators should be removed. + */ +export function removeRevalidators(type: EventType) { + removeEventHandler(type); + + const flagIndex = type === 'focus' ? 5 : 6; + + // Clear all revalidators with this flag + revalidators.forEach((entry, key) => { + if (entry[flagIndex]) { + removeRevalidator(key); + } + }); +} + +/** + * Registers a generic revalidation event handler for the specified event type. + * Ensures the handler is only added once and only in browser environments. + * + * @param event - The type of event to listen for (e.g., 'focus', 'visibilitychange'). + */ +function addEventHandler(event: EventType) { + if (!isBrowser() || eventHandlers.has(event)) { + return; + } + + const handler = revalidateAll.bind(null, event, true); + + eventHandlers.set(event, handler); + window.addEventListener(event, handler); +} + +/** + * Removes the generic event handler for the specified event type from the window object. + * + * @param event - The type of event whose handler should be removed. + */ +function removeEventHandler(event: EventType) { + if (!isBrowser()) { + return; + } + + const handler = eventHandlers.get(event); + + if (handler) { + window.removeEventListener(event, handler); + + eventHandlers.delete(event); + } +} + +/** + * Registers a revalidation functions for a specific cache key. + * + * @param {string} key Cache key to utilize + * @param {RevalidatorFn} revalidatorFn Main revalidation function (marks in-flight requests) + * @param {number} [ttl] Time to live in milliseconds (default: 3 minutes) + * @param {number} [staleTime] Time (in seconds) after which the cache entry is considered stale + * @param {RevalidatorFn} [bgRevalidatorFn] For stale revalidation (does not mark in-flight requests) + * @param {boolean} [refetchOnFocus] Whether to revalidate on window focus + * @param {boolean} [refetchOnReconnect] Whether to revalidate on network reconnect + */ +export function addRevalidator( + key: string, + revalidatorFn: RevalidatorFn, // Main revalidation function (marks in-flight requests) + ttl?: number, + staleTime?: number, + bgRevalidatorFn?: RevalidatorFn, // For stale revalidation (does not mark in-flight requests) + refetchOnFocus?: boolean, + refetchOnReconnect?: boolean, +) { + revalidators.set(key, [ + revalidatorFn, + timeNow(), + ttl ?? DEFAULT_TTL, + staleTime, + bgRevalidatorFn, + refetchOnFocus, + refetchOnReconnect, + ]); + + if (refetchOnFocus) { + addEventHandler('focus'); + } + + if (refetchOnReconnect) { + addEventHandler('online'); + } + + if (staleTime) { + addTimeout('s:' + key, revalidate.bind(null, key, true), staleTime * 1000); + } +} + +export function removeRevalidator(key: string) { + revalidators.delete(key); + + // Clean up stale timer + removeTimeout('s:' + key); +} + +/** + * Periodically cleans up expired revalidators from the registry. + * Removes any revalidator whose TTL has expired. + * + * @param {number} intervalMs How often to run cleanup (default: 3 minutes) + * @returns {() => void} A function to stop the periodic cleanup + */ +export function startRevalidatorCleanup( + intervalMs: number = DEFAULT_TTL, +): () => void { + const intervalId = setInterval(() => { + const now = timeNow(); + + revalidators.forEach( + ([, lastUsed, ttl, , , refetchOnFocus, refetchOnReconnect], key) => { + // Skip focus-only or reconnect-only revalidators to keep them alive + if (refetchOnFocus || refetchOnReconnect) { + return; + } + + if (ttl > 0 && now - lastUsed > ttl) { + removeRevalidator(key); + } + }, + ); + }, intervalMs); + + return () => clearInterval(intervalId); +} diff --git a/src/timeout-wheel.ts b/src/timeout-wheel.ts new file mode 100644 index 00000000..e78cea54 --- /dev/null +++ b/src/timeout-wheel.ts @@ -0,0 +1,127 @@ +/** + * @module timeout-wheel + * @description + * Ultra-minimal timing wheel implementation optimized for max performance & many requests. + * For most of the cases it's 4-100x faster than setTimeout and setInterval alone. + * Provides efficient scheduling and cancellation of timeouts using a circular array. + * + * Position 0 → 1 → 2 → ... → 599 → 0 → 1 → 2 ... + * Time: 0s 1s 2s 599s 600s 601s 602s + * + * The timing wheel consists of 600 slots (one per second for 10 min). + * Each slot contains a list of timeout items, each associated with a unique key and callback. + * Timeouts are scheduled by placing them in the appropriate slot based on the delay in seconds. + * The wheel advances every second, executing and removing callbacks as their timeouts expire. + * Defaults to setTimeout if the delay exceeds 10 minutes or is not divisible by 1000. + * + * @remarks + * - Designed for minimal footprint and simplicity. + * - Only supports second-level granularity (minimum timeout: 1 second). + * - Automatically stops the internal timer when no timeouts remain. + */ + +import { noop } from './utils'; + +type TimeoutCallback = () => unknown | Promise; +type TimeoutItem = [string, TimeoutCallback]; // [key, callback] + +const WHEEL_SIZE = 600; // 600 slots for 10 min (1 slot per second) +const SECOND = 1000; // 1 second in milliseconds +const MAX_WHEEL_MS = WHEEL_SIZE * SECOND; +const wheel: TimeoutItem[][] = Array(WHEEL_SIZE) + .fill(0) + .map(() => []); + +const keyMap = new Map(); +let position = 0; +let timer: NodeJS.Timeout | null = null; + +const handleCallback = ([key, callback]: TimeoutItem): void => { + keyMap.delete(key); + + try { + const result = callback(); + if (result && result instanceof Promise) { + // Silently ignore async errors to prevent wheel from stopping + result.catch(noop); + } + } catch { + // Ignore callback errors to prevent wheel from stopping + } +}; + +export const addTimeout = ( + key: string, + cb: TimeoutCallback, + ms: number, +): void => { + removeTimeout(key); + + // Fallback to setTimeout if wheel size is exceeded or ms is not divisible by SECOND + if (ms > MAX_WHEEL_MS || ms % SECOND !== 0) { + keyMap.set(key, [setTimeout(handleCallback.bind(null, [key, cb]), ms)]); // Store timeout ID instead of slot + + return; + } + + // No need for Math.ceil here since ms is guaranteed by modulo above + const seconds = ms / SECOND; + const slot = (position + seconds) % WHEEL_SIZE; + + wheel[slot].push([key, cb]); + keyMap.set(key, slot); + + if (!timer) { + timer = setInterval(() => { + position = (position + 1) % WHEEL_SIZE; + wheel[position].forEach(handleCallback); + wheel[position] = []; + + if (!keyMap.size && timer) { + clearInterval(timer); + timer = null; + } + }, SECOND); + } +}; + +export const removeTimeout = (key: string): void => { + const slotOrTimeout = keyMap.get(key); + + if (slotOrTimeout !== undefined) { + // It's a Timeout object from setTimeout + if (Array.isArray(slotOrTimeout)) { + clearTimeout(slotOrTimeout[0]); + } else { + wheel[slotOrTimeout].splice( + wheel[slotOrTimeout].findIndex(([k]) => k === key), + 1, + ); + } + + keyMap.delete(key); + + if (!keyMap.size && timer) { + clearInterval(timer); + timer = null; + } + } +}; + +export const clearAllTimeouts = () => { + // Clear native setTimeout timeouts first! + keyMap.forEach((value) => { + if (Array.isArray(value)) { + clearTimeout(value[0]); + } + }); + + if (timer) { + clearInterval(timer); + timer = null; + } + + keyMap.clear(); + wheel.forEach((slot) => (slot.length = 0)); + position = 0; +}; diff --git a/src/tsconfig.json b/src/tsconfig.json index 145f6396..3b9438d8 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -21,7 +21,14 @@ "noImplicitReturns": true, "noImplicitUseStrict": false, "noFallthroughCasesInSwitch": true, - "lib": ["esnext", "ES2018", "dom"] + "lib": ["esnext", "ES2018", "dom"], + "baseUrl": ".", + "paths": { + "react": ["../node_modules/react"], + "react-dom": ["../node_modules/react-dom"], + "fetchff/*": ["../src/*"], + "fetchff": ["../src/index.ts"] + } }, "exclude": ["demo", "scripts", "node_modules"] } diff --git a/src/types/api-handler.ts b/src/types/api-handler.ts index eeec9c5a..f31ff0f8 100644 --- a/src/types/api-handler.ts +++ b/src/types/api-handler.ts @@ -1,12 +1,10 @@ +import type { DefaultRequestType } from './request-handler'; +import type { Req } from './request-handler'; /* eslint-disable @typescript-eslint/no-explicit-any */ import type { RequestConfig, - RequestHandlerConfig, FetchResponse, - RequestHandlerReturnType, - CreatedCustomFetcherInstance, DefaultResponse, - ExtendedRequestConfig, } from './request-handler'; // Common type definitions @@ -19,14 +17,21 @@ declare const emptyObjectSymbol: unique symbol; export type EmptyObject = { [emptyObjectSymbol]?: never }; -export type DefaultParams = Record; -export type DefaultUrlParams = Record; +export type DefaultParams = + | Record + | URLSearchParams + | NameValuePair[] + | EmptyObject + | null; + +export type DefaultUrlParams = Record; export type DefaultPayload = Record; export declare type QueryParams = | (ParamsType & EmptyObject) | URLSearchParams | NameValuePair[] + | EmptyObject | null; export declare type UrlPathParams = @@ -42,100 +47,141 @@ export declare type BodyPayload = | PayloadType[] | null; -// Helper types declared outside the interface -export type FallbackValue = [T] extends [never] ? U : D; +type EndpointDefaults = Endpoint; -export type FinalResponse = FallbackValue< - Response, - ResponseData ->; +/** + * Represents an API endpoint definition with optional type parameters for various request and response components. + * + * @template T - An object that can specify the following optional properties: + * @property response - The expected response type returned by the endpoint (default: `DefaultResponse`). + * @property body - The type of the request body accepted by the endpoint (default: `BodyPayload`). + * @property params - The type of the query parameters accepted by the endpoint (default: `QueryParams`). + * @property urlPathParams - The type of the path parameters accepted by the endpoint (default: `UrlPathParams`). + * + * @example + * interface EndpointTypes { + * getUser: Endpoint<{ response: UserResponse }>; + * getPosts: Endpoint<{ + * response: PostsResponse; + * params: PostsQueryParams; + * urlPathParams: PostsUrlPathParams; + * body: PostsRequestBody; + * }>; + * } + */ +export type Endpoint = + EndpointFunction; -export type FinalParams = [ - ParamsType, -] extends [never] - ? DefaultParams - : [Response] extends [never] - ? DefaultParams - : ParamsType | EmptyObject; +// Helper to support 4 generics +export type EndpointReq< + ResponseData extends DefaultResponse | undefined = DefaultResponse, + RequestBody extends DefaultPayload | undefined = DefaultPayload, + QueryParams extends DefaultParams | undefined = DefaultParams, + UrlPathParams extends DefaultUrlParams | undefined = DefaultUrlParams, +> = Endpoint>; + +type MergeEndpointShape< + O extends Partial, + T extends DefaultRequestType, +> = { + response: O extends { response: infer R } + ? R + : T extends { response: infer R } + ? R + : DefaultResponse; + body: O extends { body: infer B } + ? B + : T extends { body: infer B } + ? B + : BodyPayload; + params: O extends { params: infer P } + ? P + : T extends { params: infer P } + ? P + : QueryParams; + urlPathParams: O extends { urlPathParams: infer U } + ? U + : T extends { urlPathParams: infer U } + ? U + : UrlPathParams; +}; interface EndpointFunction< - ResponseData, - QueryParams_, - PathParams, - RequestBody_, + T extends Partial = DefaultRequestType, > { - ( - requestConfig?: ExtendedRequestConfig< - FallbackValue, - FinalParams, - FinalParams, - FallbackValue + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + = {}>( + requestConfig?: RequestConfig< + MergeEndpointShape['response'], + MergeEndpointShape['params'], + MergeEndpointShape['urlPathParams'], + MergeEndpointShape['body'] >, - ): Promise>>; + ): Promise< + FetchResponse< + MergeEndpointShape['response'], + MergeEndpointShape['body'], + MergeEndpointShape['params'], + MergeEndpointShape['urlPathParams'] + > + >; } -export interface RequestEndpointFunction { - < - ResponseData = never, - QueryParams_ = never, - UrlParams = never, - RequestBody = never, - >( - endpointName: keyof EndpointsMethods | string, +export interface RequestEndpointFunction { + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + = {}>( + endpointNameOrUrl: keyof EndpointTypes | string, requestConfig?: RequestConfig< - FinalResponse, - FinalParams, - FinalParams, - FallbackValue + MergeEndpointShape['response'], + MergeEndpointShape['params'], + MergeEndpointShape['urlPathParams'], + MergeEndpointShape['body'] >, - ): Promise>>; + ): Promise< + FetchResponse< + MergeEndpointShape['response'], + MergeEndpointShape['body'], + MergeEndpointShape['params'], + MergeEndpointShape['urlPathParams'] + > + >; } -/** - * Represents an API endpoint handler with support for customizable query parameters, URL path parameters, - * and request configuration. - * - * The overloads allow customization of the returned data type (`ReturnedData`), query parameters (`T`), - * and URL path parameters (`T2`). - * - * @template ResponseData - The type of the response data (default: `DefaultResponse`). - * @template QueryParams - The type of the query parameters (default: `QueryParams`). - * @template PathParams - The type of the URL path parameters (default: `UrlPathParams`). - * @template RequestBody - The type of the Requesty Body (default: `BodyPayload`). - * - * @example - * interface EndpointsMethods { - * getUser: Endpoint; - * getPosts: Endpoint; - * } - */ -export declare type Endpoint< - ResponseData = DefaultResponse, - QueryParams_ = QueryParams, - PathParams = UrlPathParams, - RequestBody = BodyPayload, -> = EndpointFunction; - -// Setting 'unknown here lets us infer typings for non-predefined endpoints with dynamically set generic response data -type EndpointDefaults = Endpoint; +type MergeWithEndpointDef< + EndpointTypes, + K extends keyof EndpointTypes, + O extends Partial, +> = MergeEndpointShape< + O, + EndpointTypes[K] extends Endpoint ? S : DefaultRequestType +>; -type AFunction = (...args: any[]) => any; +type EndpointMethod = < + // eslint-disable-next-line @typescript-eslint/no-empty-object-type + O extends Partial = {}, +>( + requestConfig?: RequestConfig< + MergeWithEndpointDef['response'], + MergeWithEndpointDef['params'], + MergeWithEndpointDef['urlPathParams'], + MergeWithEndpointDef['body'] + >, +) => Promise< + FetchResponse< + MergeWithEndpointDef['response'], + MergeWithEndpointDef['body'], + MergeWithEndpointDef['params'], + MergeWithEndpointDef['urlPathParams'] + > +>; /** - * Maps the method names from `EndpointsMethods` to their corresponding `Endpoint` type definitions. + * Maps the method names from `EndpointTypes` to their corresponding `Endpoint` type definitions. * - * @template EndpointsMethods - The object containing endpoint method definitions. + * @template EndpointTypes - The object containing endpoint method definitions. */ -type EndpointsRecord = { - [K in keyof EndpointsMethods]: EndpointsMethods[K] extends AFunction - ? EndpointsMethods[K] // Map function signatures directly - : EndpointsMethods[K] extends Endpoint< - infer ResponseData, - infer QueryParams, - infer UrlPathParams - > - ? Endpoint // Method is an Endpoint type - : EndpointDefaults; // Fallback to default Endpoint type +type EndpointsRecord = { + [K in keyof EndpointTypes]: EndpointMethod; }; /** @@ -156,63 +202,64 @@ export type RequestConfigUrlRequired = Omit & { /** * Configuration for API endpoints, where each key is an endpoint name or string, and the value is the request configuration. * - * @template EndpointsMethods - The object containing endpoint method definitions. + * @template EndpointTypes - The object containing endpoint method definitions. */ -export type EndpointsConfig = Record< - keyof EndpointsMethods | string, +export type EndpointsConfig = Record< + keyof EndpointTypes | string, RequestConfigUrlRequired >; /** - * Part of the endpoints configuration, derived from `EndpointsSettings` based on the `EndpointsMethods`. + * Part of the endpoints configuration, derived from `EndpointsSettings` based on the `EndpointTypes`. * * This type handles defaulting to endpoints configuration when particular Endpoints Methods are not provided. * * @template EndpointsSettings - The configuration object for endpoints. - * @template EndpointsMethods - The object containing endpoint method definitions. + * @template EndpointTypes - The object containing endpoint method definitions. */ -type EndpointsConfigPart = [ +type EndpointsConfigPart = [ EndpointsSettings, ] extends [never] ? unknown - : DefaultEndpoints>; + : DefaultEndpoints>; /** * Provides the methods available from the API handler, combining endpoint record types, endpoints configuration, * and default methods. * - * @template EndpointsMethods - The object containing endpoint method definitions. + * @template EndpointTypes - The object containing endpoint method definitions. * @template EndpointsSettings - The configuration object for endpoints. */ export type ApiHandlerMethods< - EndpointsMethods extends object, + EndpointTypes extends object, EndpointsSettings, -> = EndpointsRecord & // Provided interface - EndpointsConfigPart & // Derived defaults from 'endpoints' - ApiHandlerDefaultMethods; // Returned API Handler methods +> = EndpointsRecord & // Provided interface + EndpointsConfigPart & // Derived defaults from 'endpoints' + ApiHandlerDefaultMethods; // Returned API Handler methods /** * Defines the default methods available within the API handler. * * This includes configuration, endpoint settings, request handler, instance retrieval, and a generic request method. * - * @template EndpointsMethods - The object containing endpoint method definitions. + * @template EndpointTypes - The object containing endpoint method definitions. */ -export type ApiHandlerDefaultMethods = { - config: ApiHandlerConfig; - endpoints: EndpointsConfig; - requestHandler: RequestHandlerReturnType; - getInstance: () => CreatedCustomFetcherInstance | null; - request: RequestEndpointFunction; +export type ApiHandlerDefaultMethods = { + config: ApiHandlerConfig; + endpoints: EndpointsConfig; + request: RequestEndpointFunction; }; +type RequireApiUrlOrBaseURL = + | { apiUrl: string; baseURL?: never } + | { apiUrl?: never; baseURL: string }; + /** * Configuration for the API handler, including API URL and endpoints. * - * @template EndpointsMethods - The object containing endpoint method definitions. + * @template EndpointTypes - The object containing endpoint method definitions. */ -export interface ApiHandlerConfig - extends RequestHandlerConfig { - apiUrl: string; - endpoints: EndpointsConfig; -} +export type ApiHandlerConfig = RequestConfig & + RequireApiUrlOrBaseURL & { + endpoints: EndpointsConfig; + }; diff --git a/src/types/cache-manager.ts b/src/types/cache-manager.ts index 9ebb1911..4ce1efc3 100644 --- a/src/types/cache-manager.ts +++ b/src/types/cache-manager.ts @@ -1,5 +1,6 @@ export interface CacheEntry { data: T; - timestamp: number; - isLoading: boolean; + time: number; + stale?: number; // Time in milliseconds when the cache entry is considered stale + expiry?: number; // Time in milliseconds when the cache entry expires } diff --git a/src/types/index.ts b/src/types/index.ts index 99de1dcd..cf13bade 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,2 +1,3 @@ export * from './api-handler'; export * from './request-handler'; +export * from './react-hooks'; diff --git a/src/types/interceptor-manager.ts b/src/types/interceptor-manager.ts index 389b4ebe..367eaff3 100644 --- a/src/types/interceptor-manager.ts +++ b/src/types/interceptor-manager.ts @@ -7,31 +7,56 @@ import type { import type { DefaultResponse, FetchResponse, - RequestHandlerConfig, + RequestConfig, ResponseError, } from './request-handler'; -export type RequestInterceptor = ( - config: RequestHandlerConfig, +export type InterceptorFunction = ( + object: T, + ...args: Args +) => Promise; + +export type RequestInterceptor< + ResponseData = DefaultResponse, + RequestBody = DefaultPayload, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, +> = ( + config: RequestConfig, ) => - | RequestHandlerConfig + | RequestConfig | void - | Promise> + | Promise> | Promise; -export type ResponseInterceptor = ( - response: FetchResponse, +export type ResponseInterceptor< + ResponseData = DefaultResponse, + RequestBody = DefaultPayload, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, +> = ( + response: FetchResponse, ) => - | FetchResponse + | FetchResponse + | Promise> | void - | Promise> | Promise; export type ErrorInterceptor< ResponseData = DefaultResponse, + RequestBody = DefaultPayload, QueryParams = DefaultParams, PathParams = DefaultUrlParams, +> = ( + error: ResponseError, +) => void | Promise; + +export type RetryInterceptor< + ResponseData = DefaultResponse, RequestBody = DefaultPayload, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, > = ( - error: ResponseError, -) => unknown; + response: FetchResponse, + retryAttempt: number, +) => void | Promise; diff --git a/src/types/queue-manager.ts b/src/types/queue-manager.ts deleted file mode 100644 index 2014ee90..00000000 --- a/src/types/queue-manager.ts +++ /dev/null @@ -1,15 +0,0 @@ -import type { RequestConfig } from './request-handler'; - -export type RequestsQueue = WeakMap; - -type timeoutId = NodeJS.Timeout | null; -type timestamp = number; -type isCancellable = boolean; - -export type QueueItem = [ - AbortController, - timeoutId, - timestamp, - isCancellable, - Promise?, -]; diff --git a/src/types/react-hooks.ts b/src/types/react-hooks.ts new file mode 100644 index 00000000..bbe65f89 --- /dev/null +++ b/src/types/react-hooks.ts @@ -0,0 +1,115 @@ +import type { + DefaultPayload, + DefaultParams, + DefaultUrlParams, +} from './api-handler'; +import type { + DefaultResponse, + FetchResponse, + MutationSettings, +} from './request-handler'; + +type RefetchFunction< + ResponseData = DefaultResponse, + RequestBody = DefaultPayload, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, +> = ( + // Whatever truthy value to force a refresh, or any other value. + // It comes handy when passing the refetch directly to a click handler. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + forceRefresh?: boolean | any, +) => Promise | null>; + +export interface UseFetcherResult< + ResponseData = DefaultResponse, + RequestBody = DefaultPayload, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, +> { + /** + * The fetched data, or null if not yet available. + * This will be null if the request is in progress or if no data has been fetched yet. + */ + data: + | FetchResponse['data'] + | null; + /** + * The error encountered during the fetch operation, if any. + * If the request was successful, this will be null. + */ + error: + | FetchResponse['error'] + | null; + /** + * Indicates if the request is currently validating or fetching data. + * This is true when the request is in progress, including revalidations. + */ + isFetching: boolean; + /** + * Indicates if the request is currently loading data. + * This is true when the request is in progress, including initial fetches. + * It will be false if the data is already cached and no new fetch is in progress. + */ + isLoading: boolean; + /** + * Function to mutate the cached data. + * It updates the cache with new data and optionally triggers revalidation. + * + * @param {ResponseData} data - The new data to set in the cache. + * @param {MutationSettings} [mutationSettings] - Optional settings for the mutation. + * - `revalidate`: If true, it will trigger a revalidation after mutating the cache. + * @returns {Promise | null>} The updated response or null if no cache key is set. + */ + mutate: ( + data: FetchResponse< + ResponseData, + RequestBody, + QueryParams, + PathParams + >['data'], + mutationSettings?: MutationSettings, + ) => Promise | null>; + /** + * Function to refetch the data from the server. + * This will trigger a new fetch operation and update the cache with the latest data. + * + * @returns {Promise | null>} The new fetch response or null if no URL is set. + */ + refetch: RefetchFunction; + + /** + * The configuration object used for this fetcher instance. + * This contains the settings and options passed to the hook. + */ + config: + | FetchResponse< + ResponseData, + RequestBody, + QueryParams, + PathParams + >['config'] + | undefined; + + /** + * The HTTP headers returned with the response, or undefined if not available. + */ + headers: + | FetchResponse< + ResponseData, + RequestBody, + QueryParams, + PathParams + >['headers'] + | undefined; +} diff --git a/src/types/request-handler.ts b/src/types/request-handler.ts index 5567991a..1a2eee94 100644 --- a/src/types/request-handler.ts +++ b/src/types/request-handler.ts @@ -11,6 +11,7 @@ import type { ErrorInterceptor, RequestInterceptor, ResponseInterceptor, + RetryInterceptor, } from './interceptor-manager'; export type Method = @@ -39,27 +40,59 @@ export type DefaultResponse = any; export type NativeFetch = typeof fetch; -export interface FetcherInstance { - create: ( - config?: RequestHandlerConfig, - ) => RequestInstance; -} +/** + * A short-hand type to create a generic request structure with customizable types for response data, request body, query parameters, and URL path parameters. + * + * @template ResponseData - The type of the response data. Defaults to `DefaultResponse`. + * @template RequestBody - The type of the request body. Defaults to `DefaultPayload`. + * @template QueryParams - The type of the query parameters. Defaults to `DefaultParams`. + * @template UrlPathParams - The type of the URL path parameters. Defaults to `DefaultUrlParams`. + * + * @property response - The response data of type `ResponseData`. + * @property body - The request body of type `RequestBody`. + * @property params - The query parameters of type `QueryParams`. + * @property urlPathParams - The URL path parameters of type `UrlPathParams`. + */ +export type Req< + ResponseData = DefaultResponse, + RequestBody = DefaultPayload, + QueryParams = DefaultParams, + UrlPathParams = DefaultUrlParams, +> = { + response: ResponseData; + params: QueryParams; + urlPathParams: UrlPathParams; + body: RequestBody; +}; -export interface CreatedCustomFetcherInstance { - request< - ResponseData = DefaultResponse, - QueryParams = DefaultParams, - PathParams = DefaultUrlParams, - RequestBody = DefaultPayload, - >( - requestConfig: RequestConfig< - ResponseData, - QueryParams, - PathParams, - RequestBody - >, - ): PromiseLike>; -} +export type DefaultRequestType = { + response?: DefaultResponse; + body?: DefaultPayload; + params?: QueryParams; + urlPathParams?: UrlPathParams; +}; + +export type CustomFetcher = < + ResponseData = DefaultResponse, + RequestBody = DefaultPayload, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, +>( + url: string, + config?: RequestConfig< + ResponseData, + QueryParams, + PathParams, + RequestBody + > | null, +) => + | PromiseLike< + FetchResponse + > + | FetchResponse + | Response + | PromiseLike + | PromiseLike; export type ErrorHandlingStrategy = | 'reject' @@ -77,21 +110,68 @@ export interface ExtendedResponse< QueryParams = DefaultParams, PathParams = DefaultUrlParams, > extends Omit { + /** + * Return response data as parsed JSON (default) or the raw response body. + */ data: ResponseData extends [unknown] ? any : ResponseData; + + /** + * Error object if the request failed. + * This will be `null` if the request was successful. + */ error: ResponseError< ResponseData, + RequestBody, QueryParams, - PathParams, - RequestBody + PathParams > | null; + /** + * Plain headers object containing the response headers. + */ headers: HeadersObject & HeadersInit; + + /** + * Request configuration used to make the request. + */ config: RequestConfig; + + /** + * Function to mutate the cached data. + * It updates the cache with new data and optionally triggers revalidation. + * + * @param {ResponseData} data - The new data to set in the cache. + * @param {MutationSettings} [mutationSettings] - Optional settings for the mutation. + * - `revalidate`: If true, it will trigger a revalidation after mutating the cache. + * @returns {Promise | null>} The updated response or null if no cache key is set. + */ + mutate: ( + data: FetchResponse< + ResponseData, + RequestBody, + QueryParams, + PathParams + >['data'], + mutationSettings?: MutationSettings, + ) => Promise | null>; + + /** + * Indicates whether the request is currently being fetched. + */ + isFetching: boolean; } /** * Represents the response from a `fetchf()` request. * * @template ResponseData - The type of the data returned in the response. + * @template RequestBody - The type of the request body sent in the request. + * @template QueryParams - The type of the query parameters used in the request. + * @template PathParams - The type of the path parameters used in the request. */ export type FetchResponse< ResponseData = any, @@ -102,59 +182,98 @@ export type FetchResponse< export interface ResponseError< ResponseData = DefaultResponse, + RequestBody = DefaultPayload, QueryParams = DefaultParams, PathParams = DefaultUrlParams, - RequestBody = DefaultPayload, > extends Error { status: number; statusText: string; + isCancelled: boolean; request: RequestConfig; config: RequestConfig; - response: FetchResponse | null; + response: FetchResponse< + ResponseData, + RequestBody, + QueryParams, + PathParams + > | null; } export type RetryFunction< ResponseData = DefaultResponse, + RequestBody = DefaultPayload, QueryParams = DefaultParams, PathParams = DefaultUrlParams, - RequestBody = DefaultPayload, > = ( - response: ExtendedResponse< - ResponseData, - RequestBody, - QueryParams, - PathParams - >, + response: FetchResponse, attempt: number, -) => Promise | boolean; +) => Promise | boolean | null; export type PollingFunction< ResponseData = DefaultResponse, + RequestBody = DefaultPayload, QueryParams = DefaultParams, PathParams = DefaultUrlParams, - RequestBody = DefaultPayload, > = ( response: FetchResponse, attempts: number, ) => boolean; -export type CacheKeyFunction = (config: FetcherConfig) => string; - -export type CacheBusterFunction = (config: FetcherConfig) => boolean; +export type CacheKeyFunction< + _ResponseData = DefaultResponse, + _RequestBody = DefaultPayload, + _QueryParams = DefaultParams, + _PathParams = DefaultUrlParams, +> = < + ResponseData = _ResponseData, + RequestBody = _RequestBody, + QueryParams = _QueryParams, + PathParams = _PathParams, +>( + config: RequestConfig, +) => string; + +export type CacheBusterFunction< + _ResponseData = DefaultResponse, + _RequestBody = DefaultPayload, + _QueryParams = DefaultParams, + _PathParams = DefaultUrlParams, +> = < + ResponseData = _ResponseData, + RequestBody = _RequestBody, + QueryParams = _QueryParams, + PathParams = _PathParams, +>( + config: RequestConfig, +) => boolean; -export type CacheSkipFunction = ( - data: ResponseData, - config: RequestConfig, +export type CacheSkipFunction< + _ResponseData = DefaultResponse, + _RequestBody = DefaultPayload, + _QueryParams = DefaultParams, + _PathParams = DefaultUrlParams, +> = < + ResponseData = _ResponseData, + RequestBody = _RequestBody, + QueryParams = _QueryParams, + PathParams = _PathParams, +>( + response: FetchResponse, + config: RequestConfig, ) => boolean; +export interface MutationSettings { + refetch?: boolean; +} + /** * Configuration object for retry related options */ -export interface RetryOptions< - ResponseData, - QueryParams, - PathParams, - RequestBody, +export interface RetryConfig< + ResponseData = DefaultResponse, + RequestBody = DefaultPayload, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, > { /** * Maximum number of retry attempts. @@ -203,36 +322,66 @@ export interface RetryOptions< retryOn?: number[]; /** - * A function to determine whether to retry based on the error and attempt number. + * A function that determines whether a failed or successful request should be retried, based on the response and the current attempt number. + * Return `true` to retry, or `false` to stop retrying. + * @param response - The response object from the failed request. + * @param attempt - The current retry attempt number (starting from 1). + * @returns `true` to retry, `false` to stop retrying, `null` to use default retry logic (retryOn headers check). */ shouldRetry?: RetryFunction< ResponseData, + RequestBody, QueryParams, - PathParams, - RequestBody + PathParams >; } /** * Configuration object for cache related options */ -export interface CacheOptions { +export interface CacheOptions< + ResponseData = DefaultResponse, + QueryParams = DefaultParams, + PathParams = DefaultUrlParams, + RequestBody = DefaultPayload, +> { /** - * Maximum time, in seconds, a cache entry is considered fresh (valid). - * After this time, the entry may be considered stale (expired). + * Time in seconds after which the cache entry is removed. + * This is the time to live (TTL) for the cache entry. + * - Set to `-1` to remove cache as soon as consumer is not using the data (e.g., a component unmounts), it is deleted from cache. + * - Set to `0` to immediately discard of cache. The cache is immediately discarded, forces fetch every time. + * - Set to `undefined` to disable cache (no cache). * - * @default 0 (no cache) + * @default undefined (no cache) */ cacheTime?: number; /** - * Cache key - * It provides a way to customize caching behavior dynamically according to different criteria. - * @param config - Request configuration. - * @default null By default it generates a unique cache key for HTTP requests based on: - * Method, URL, Query Params, Dynamic Path Params, mode, credentials, cache, redirect, referrer, integrity, headers and body + * Time in seconds for which the cache entry is considered valid (fresh). + * After this time, the entry may be considered stale (expired) and background revalidation is triggered. + * This is implementing the SWR (stale-while-revalidate) pattern. + * - Set to a number greater than `0` to specify number of seconds during which cached data is considered "fresh". + * - Set to `0` to set data as stale immediately (always eligible to refetch). + * - Set to `undefined` to disable SWR pattern (data is never considered stale). + * + * @default undefined (disable SWR pattern) or 300 (5 minutes) in libraries like React. + */ + staleTime?: number; + + /** + * Cache key generator function or string. + * Lets you customize how cache entries are identified for requests. + * - You can provide a function that returns a cache key string based on the request config. + * - You can provide a fixed string to use as the cache key. + * - Set to null to use the default cache key generator. + * + * @param config - The request configuration. + * @default null (uses the default cache key generator, which considers: method, URL, query params, path params, mode, credentials, cache, redirect, referrer, integrity, headers, and body) */ - cacheKey?: CacheKeyFunction; + cacheKey?: + | CacheKeyFunction + | string + | null; /** * Cache Buster Function @@ -240,7 +389,12 @@ export interface CacheOptions { * @param config - Request configuration. * @default (config)=>false Busting cache is disabled by default. Return true to change that */ - cacheBuster?: CacheBusterFunction; + cacheBuster?: CacheBusterFunction< + ResponseData, + RequestBody, + QueryParams, + PathParams + >; /** * Skip Cache Function @@ -249,25 +403,48 @@ export interface CacheOptions { * @param config - Request configuration. * @default (response,config)=>false Bypassing cache is disabled by default. Return true to skip cache */ - skipCache?: CacheSkipFunction; + skipCache?: CacheSkipFunction< + ResponseData, + RequestBody, + QueryParams, + PathParams + >; + + /** + * If true, error responses (non-2xx) will also be cached. + * @default false + */ + cacheErrors?: boolean; + + /** + * INTERNAL, DO NOT USE. + * This is used internally to mark requests that are automatically generated cache keys. + */ + _isAutoKey?: boolean; + + /** + * INTERNAL, DO NOT USE. + * This is used internally to store the previous cache key. + */ + _prevKey?: string | null; } /** - * ExtendedRequestConfig + * ExtendedRequestConfig * * This interface extends the standard `RequestInit` from the Fetch API, providing additional options * for handling requests, including custom error handling strategies, request interception, and more. */ export interface ExtendedRequestConfig< ResponseData = any, + RequestBody = any, QueryParams_ = any, PathParams = any, - RequestBody = any, > extends Omit, CacheOptions { /** * Custom error handling strategy for the request. - * - `'reject'`: Rejects the promise with an error. + * - `'reject'`: Rejects the promise with an error (default). * - `'silent'`: Silently handles errors without rejecting. * - `'defaultResponse'`: Returns a default response in case of an error. * - `'softFail'`: Returns a partial response with error details. @@ -275,7 +452,8 @@ export interface ExtendedRequestConfig< strategy?: ErrorHandlingStrategy; /** - * A default response to return if the request fails and the strategy is set to `'defaultResponse'`. + * A default response to return if the request fails + * @default undefined */ defaultResponse?: any; @@ -284,6 +462,14 @@ export interface ExtendedRequestConfig< */ flattenResponse?: boolean; + /** + * Function to transform or select a subset of the response data before it is returned. + * This is called with the raw response data and should return the transformed data. + * @param data - The raw response data. + * @returns The transformed or selected data. + */ + select?: (data: T) => R; + /** * If true, the ongoing previous requests will be automatically cancelled. * @default false @@ -296,6 +482,35 @@ export interface ExtendedRequestConfig< */ rejectCancelled?: boolean; + /** + * If true, automatically revalidates the request when the window regains focus. + * @default false + */ + refetchOnFocus?: boolean; + + /** + * If true, automatically revalidates the request when the browser regains network connectivity. + * @default false + */ + refetchOnReconnect?: boolean; + + /** + * Whether to automatically run the request as soon as the handler is created. + * - If `true`, the request is sent immediately (useful for React/Vue hooks). + * - If `false`, you must call a function to trigger the request manually. + * Primarily used in UI frameworks (e.g., React/Vue hooks); has no effect for direct fetchf() usage. + * @default true + */ + immediate?: boolean; + + /** + * If true, keeps the previous data while fetching new data. + * Useful for UI frameworks to avoid showing empty/loading states between requests. + * Primarily used in UI frameworks (e.g., React/Vue hooks); has no effect for direct fetchf() usage. + * @default false + */ + keepPreviousData?: boolean; + /** * An object representing dynamic URL path parameters. * For example, `{ userId: 1 }` would replace `:userId` in the URL with `1`. @@ -305,7 +520,7 @@ export interface ExtendedRequestConfig< /** * Configuration options for retrying failed requests. */ - retry?: RetryOptions; + retry?: RetryConfig; /** * The URL of the request. This can be a full URL or a relative path combined with `baseURL`. @@ -363,22 +578,37 @@ export interface ExtendedRequestConfig< * A function or array of functions to intercept the request before it is sent. */ onRequest?: - | RequestInterceptor - | RequestInterceptor[]; + | RequestInterceptor + | RequestInterceptor[]; /** * A function or array of functions to intercept the response before it is resolved. */ onResponse?: - | ResponseInterceptor - | ResponseInterceptor[]; + | ResponseInterceptor + | ResponseInterceptor< + ResponseData, + RequestBody, + QueryParams_, + PathParams + >[]; /** * A function to handle errors that occur during the request or response processing. */ onError?: - | ErrorInterceptor - | ErrorInterceptor[]; + | ErrorInterceptor + | ErrorInterceptor[]; + + /** + * A function that is called after each failed request attempt, before the retry delay. + * Can be used for logging, side effects, or modifying the response/config before retrying. + * @param response - The response object from the failed request. + * @param attempt - The current retry attempt number (starting from 0). + */ + onRetry?: + | RetryInterceptor + | RetryInterceptor[]; /** * The maximum time (in milliseconds) the request can take before automatically being aborted. 0 seconds disables the timeout. @@ -419,9 +649,9 @@ export interface ExtendedRequestConfig< */ shouldStopPolling?: PollingFunction< ResponseData, + RequestBody, QueryParams_, - PathParams, - RequestBody + PathParams >; /** @@ -429,12 +659,12 @@ export interface ExtendedRequestConfig< * When `null`, the default fetch behavior is used. * * @example: - * const customFetcher: FetcherInstance = { create: () => ({ request: (config) => fetch(config.url) }) }; - * fetchf('/endpoint', { fetcher: customFetcher }); + * const customFetcher: CustomFetcher = (url, config) => fetch(url, config); + * const data = await fetchf('/endpoint', { fetcher: customFetcher }); * * @default null */ - fetcher?: FetcherInstance | null; + fetcher?: CustomFetcher | null; /** * A custom logger instance to handle warnings and errors. @@ -446,54 +676,150 @@ export interface ExtendedRequestConfig< * * @default null (Logging is disabled) */ - logger?: Logger | null; + logger?: FetcherLogger | null; + + // ============================================================================== + // Properties for compatibility with React Query, SWR and other popular libraries + // They are marked as deprecated so to ease migration to the new API of fetchff. + // ============================================================================== + + /** + * @deprecated Use the "immediate" property instead for controlling request execution. + * This property is provided for compatibility with React Query. + */ + enabled?: boolean; + + /** + * @deprecated Use the "refetchOnFocus" property instead for controlling refetch on window focus. + * This property is provided for compatibility with React Query. + */ + refetchOnWindowFocus?: boolean; + + /** + * @deprecated Use "onResponse" instead for transforming response data. + * This property is provided for compatibility with React Query. + */ + onSuccess?: any; + + /** + * @deprecated Use "onResponse" or "onError" instead for handling settled requests. + * This property is provided for compatibility with React Query. + */ + onSettled?: any; + + /** + * @deprecated Use the "strategy: 'reject'" property instead for enabling Suspense mode. + * If true, enables Suspense mode for UI frameworks like React. + * Suspense mode will throw a promise while loading, allowing components to suspend rendering. + * This property is provided for compatibility with React Query. + * @default false + */ + suspense?: boolean; + + /** + * @deprecated Use "immediate" instead for controlling request execution on component mount. + * If true, automatically retries the request when the handler/component mounts. + * This property is provided for compatibility with React Query. + * @default false + */ + retryOnMount?: boolean; + + /** + * @deprecated Use the "pollingInterval" property instead for controlling periodic refetching. + * This property is provided for compatibility with React Query. + */ + refetchInterval?: number; + + /** + * @deprecated Use "defaultResponse" instead. + * If set, provides fallback data to use when the request fails or is loading. + * This property is provided for compatibility with React Query. + */ + fallbackData?: any; + + // refetchIntervalInBackground?: boolean; + // initialData?: unknown; + // isPaused?: boolean; + // onLoading?: (data: any) => void; + // broadcastChannel?: string; + // revalidateOnBlur?: boolean; + // revalidateOnVisibilityChange?: boolean; + // isLoadingSlow + + // SWR: + + /** + * @deprecated Use "dedupeTime" instead for controlling request deduplication. + * If set, requests made within this interval (in milliseconds) will be deduplicated. + * This property is provided for compatibility with SWR. + */ + dedupingInterval?: number; + + /** + * @deprecated Use "pollingInterval" instead for periodic refresh of the request. + * If set, enables periodic refresh of the request at the specified interval (in milliseconds). + * Useful for polling or auto-refresh scenarios. + * This property is provided for compatibility with SWR. + */ + refreshInterval?: number; + + /** + * @deprecated Use "pollingInterval" instead for enabling periodic refresh. + * If true, enables periodic refresh of the request. + * This property is provided for compatibility with SWR. + */ + refreshIntervalEnabled?: boolean; + + /** + * @deprecated Use the "refetchOnReconnect" property instead for controlling refetch on reconnect. + * This property is provided for compatibility with SWR. + */ + revalidateOnReconnect?: boolean; + + /** + * @deprecated Use the "refetchOnFocus" property instead for controlling refetch on window focus. + * This property is provided for compatibility with with SWR. + */ + revalidateOnFocus?: boolean; + + /** + * @deprecated Use the "fetcher" property instead for providing a custom fetch function. + * This property is provided for compatibility with React Query. + */ + queryFn?: CustomFetcher | null; + + /** + * @deprecated Use the "cacheKey" property instead for customizing cache identification. + * This property is provided for compatibility with React Query and SWR. + */ + queryKey?: string | null; + + // pollingWhenHidden?: boolean; + // loadingTimeout?: number; + // refreshWhenHidden?: boolean; } -export interface Logger { +export interface FetcherLogger extends Partial { warn(message?: any, ...optionalParams: any[]): void; error?(message?: any, ...optionalParams: any[]): void; } -export type RequestHandlerConfig< - ResponseData = any, - RequestBody = any, -> = RequestConfig; - export type RequestConfig< ResponseData = any, QueryParams = any, PathParams = any, RequestBody = any, -> = ExtendedRequestConfig; +> = ExtendedRequestConfig; export type FetcherConfig< ResponseData = any, + RequestBody = any, QueryParams = any, PathParams = any, - RequestBody = any, > = Omit< - ExtendedRequestConfig, + ExtendedRequestConfig, 'url' > & { url: string; + cacheKey?: string | null; }; - -export interface RequestHandlerReturnType { - config: RequestHandlerConfig; - getInstance: () => CreatedCustomFetcherInstance | null; - request: < - ResponseData = DefaultResponse, - QueryParams = DefaultParams, - PathParams = DefaultUrlParams, - RequestBody = DefaultPayload, - >( - url: string, - config?: RequestConfig< - ResponseData, - QueryParams, - PathParams, - RequestBody - > | null, - shouldMerge?: boolean, - ) => Promise>; -} diff --git a/src/utils.ts b/src/utils.ts index 4dd20caa..79cdebda 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -10,8 +10,6 @@ import type { // Prevent stack overflow with recursion depth limit const MAX_DEPTH = 10; -const dangerousProps = ['__proto__', 'constructor', 'prototype']; - export function isSearchParams(data: unknown): boolean { return data instanceof URLSearchParams; } @@ -54,18 +52,12 @@ export function shallowSerialize(obj: Record): string { * @param obj - The object to sanitize * @returns A new object without dangerous properties */ -export function sanitizeObject>( - obj: T, -): Partial { - if (!obj || typeof obj !== OBJECT || Array.isArray(obj)) { - return obj; - } - +export function sanitizeObject>(obj: T): T { const safeObj = { ...obj }; - dangerousProps.forEach((prop) => { - delete safeObj[prop]; - }); + delete safeObj.__proto__; + delete (safeObj as any).constructor; + delete safeObj.prototype; return safeObj; } @@ -80,19 +72,15 @@ export function sanitizeObject>( * @returns {Object} - A new object with keys sorted in ascending order. */ export function sortObject(obj: Record): object { - const sortedObj = {} as Record; const keys = Object.keys(obj); keys.sort(); + const sortedObj = {} as Record; + for (let i = 0, len = keys.length; i < len; i++) { const key = keys[i]; - // Skip dangerous property names to prevent prototype pollution - if (dangerousProps.includes(key)) { - continue; - } - sortedObj[key] = obj[key]; } @@ -226,6 +214,22 @@ export function replaceUrlPathParams( }); } +/** + * Determines whether the provided URL is absolute. + * + * An absolute URL contains a scheme (e.g., "http://", "https://"). + * + * @param url - The URL string to check. + * @returns `true` if the URL is absolute, otherwise `false`. + */ +export function isAbsoluteUrl(url: string): boolean { + return url.includes('://'); +} + +export const timeNow = () => Date.now(); + +export const noop = () => {}; + /** * Checks if a value is JSON serializable. * @@ -241,7 +245,7 @@ export function replaceUrlPathParams( export function isJSONSerializable(value: any): boolean { const t = typeof value; - if (t === UNDEFINED || value === null) { + if (value === undefined || value === null) { return false; } @@ -261,7 +265,7 @@ export function isJSONSerializable(value: any): boolean { return false; } - if (value instanceof Date) { + if (value instanceof Date || isSearchParams(value)) { return false; } @@ -304,12 +308,7 @@ export function flattenData(data: any, depth = 0): any { return data; } - if ( - data && - isObject(data) && - typeof data.data !== UNDEFINED && - Object.keys(data).length === 1 - ) { + if (data && isObject(data) && typeof data.data !== UNDEFINED) { return flattenData(data.data, depth + 1); } @@ -351,3 +350,30 @@ export function processHeaders( return headersObject; } + +/** + * Determines if the current environment is a browser. + * + * @returns {boolean} - True if running in a browser environment, false otherwise. + */ +export function isBrowser(): boolean { + // For node and and some mobile frameworks like React Native, `add/removeEventListener` doesn't exist on window! + return ( + typeof window !== UNDEFINED && typeof window.addEventListener === FUNCTION + ); +} + +/** + * Detects if the user is on a slow network connection + * @returns {boolean} True if connection is slow, false otherwise or if detection unavailable + */ +export const isSlowConnection = (): boolean => { + // Only works in browser environments + if (!isBrowser()) { + return false; + } + + const conn = navigator && (navigator as any).connection; + + return conn && ['slow-2g', '2g', '3g'].includes(conn.effectiveType); +}; diff --git a/test/api-handler.spec.ts b/test/api-handler.spec.ts index cabff17d..6228d748 100644 --- a/test/api-handler.spec.ts +++ b/test/api-handler.spec.ts @@ -6,10 +6,7 @@ import fetchMock from 'fetch-mock'; describe('API Handler', () => { fetchMock.mockGlobal(); - const fetcher = { - create: jest.fn().mockReturnValue({ request: jest.fn() }), - }; - + const fetcher = jest.fn(); const apiUrl = 'http://example.com/api/'; const config = { fetcher, @@ -26,12 +23,6 @@ describe('API Handler', () => { done(); }); - it('getInstance() - should obtain method of the API request provider', () => { - const api = createApiFetcher(config); - - expect(typeof (api.getInstance() as any).request).toBe('function'); - }); - describe('get()', () => { it('should trigger request handler for an existent endpoint', async () => { const api = createApiFetcher(config); @@ -69,17 +60,14 @@ describe('API Handler', () => { name: 'Mark', }; - jest - .spyOn(api.requestHandler, 'request') - .mockResolvedValueOnce(userDataMock as any); + jest.spyOn(api, 'request').mockResolvedValueOnce(userDataMock as any); const response = await api.getUserByIdAndName({ urlPathParams }); - expect(api.requestHandler.request).toHaveBeenCalledTimes(1); - expect(api.requestHandler.request).toHaveBeenCalledWith( - '/user-details/:id/:name', - { url: '/user-details/:id/:name', urlPathParams }, - ); + expect(api.request).toHaveBeenCalledTimes(1); + expect(api.request).toHaveBeenCalledWith('getUserByIdAndName', { + urlPathParams, + }); expect(response).toBe(userDataMock); }); @@ -94,20 +82,18 @@ describe('API Handler', () => { 'Content-Type': 'application/json', }; - jest - .spyOn(api.requestHandler, 'request') - .mockResolvedValueOnce(userDataMock as any); + jest.spyOn(api, 'request').mockResolvedValueOnce(userDataMock as any); const response = await api.getUserByIdAndName({ urlPathParams, headers, }); - expect(api.requestHandler.request).toHaveBeenCalledTimes(1); - expect(api.requestHandler.request).toHaveBeenCalledWith( - '/user-details/:id/:name', - { url: '/user-details/:id/:name', headers, urlPathParams }, - ); + expect(api.request).toHaveBeenCalledTimes(1); + expect(api.request).toHaveBeenCalledWith('getUserByIdAndName', { + headers, + urlPathParams, + }); expect(response).toBe(userDataMock); }); diff --git a/test/benchmarks/BENCHMARKS.md b/test/benchmarks/BENCHMARKS.md new file mode 100644 index 00000000..52e6f9b9 --- /dev/null +++ b/test/benchmarks/BENCHMARKS.md @@ -0,0 +1,98 @@ +# Benchmarks + +This directory contains performance benchmarks for the fetchff library using [Benchmark.js](https://benchmarkjs.com/). + +## Running Benchmarks + +Run individual benchmarks: + +```bash +node test/benchmarks/operators.bench.mjs +node test/benchmarks/object-merge.bench.mjs +node test/benchmarks/fetchf.bench.mjs +``` + +## Benchmark Files + +### `operators.bench.mjs` + +Compares performance of different JavaScript operators: + +- `||` (OR operator) vs `??` (nullish coalescing) +- Used to optimize default value assignments in the library + +### `object-merge.bench.mjs` + +Tests different strategies for merging JavaScript objects: + +- **Spread operator**: `{ ...obj1, ...obj2, ...obj3 }` +- **Object.assign**: `Object.assign({}, obj1, obj2, obj3)` +- **Nested spread**: `{ ...{ ...obj1, ...obj2 }, ...obj3 }` +- **Sequential spread**: Step-by-step merging + +Critical for config merging performance in `buildConfig()` and interceptor handling. + +### `fetchf.bench.mjs` + +Benchmarks core fetchf functionality: + +- **Simple request**: Basic `fetchf()` call +- **With config options**: Error handling and timeout configuration +- **With caching**: Performance impact of caching features + +Tests real-world usage patterns to identify performance bottlenecks. + +### `swr.bench.jsx` + +Compares React SWR performance: + +- `useSWR` vs `useFetcher` +- Component mount/unmount cycles + +### `utils.mjs` + +Shared utilities for benchmark formatting: + +- Colorized output with ops/sec formatting +- Performance comparison with percentage differences +- Clean, readable benchmark results + +## Understanding Results + +Benchmark output shows: + +- **Operations per second** (higher is better) +- **Relative mean error** (±percentage) +- **Sample count** for statistical significance +- **Performance differences** between approaches + +Example output: + +``` +Simple fetchf request: 1,234,567 ops/sec ±1.23% (89 runs sampled) +fetchf with config options: 987,654 ops/sec ±2.45% (85 runs sampled) + +Fastest is Simple fetchf request +fetchf with config options is 20.00% slower than Simple fetchf request +``` + +## Adding New Benchmarks + +1. Create a new `.bench.mjs` file +2. Import Benchmark.js and utils +3. Use the standard pattern: + +```javascript +import Benchmark from 'benchmark'; +import { onComplete } from './utils.mjs'; + +const suite = new Benchmark.Suite(); + +suite + .add('Test name', () => { + // Your test code here + }) + .on('cycle', (event) => console.log(String(event.target))) + .on('complete', onComplete) + .run(); +``` diff --git a/test/benchmarks/cache-key.bench.mjs b/test/benchmarks/cache-key.bench.mjs new file mode 100644 index 00000000..908b1024 --- /dev/null +++ b/test/benchmarks/cache-key.bench.mjs @@ -0,0 +1,39 @@ +// To run this benchmark, use the following command: +// npx tsx test/benchmarks/cache-key.bench.mjs +import Benchmark from 'benchmark'; +import { generateCacheKey } from '../../dist/node/index.js'; +import { onComplete } from './utils.mjs'; + +const suite = new Benchmark.Suite(); + +// Test data +const simpleConfig = { + url: '/api/users', + method: 'GET', +}; + +const complexConfig = { + url: '/api/users', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer token', + }, + body: JSON.stringify({ name: 'John', email: 'john@example.com' }), +}; + +suite + .add('Simple GET cache key', () => { + generateCacheKey(simpleConfig); + }) + .add('Complex POST cache key', () => { + generateCacheKey(complexConfig); + }) + .add('Custom cache key', () => { + generateCacheKey({ ...simpleConfig, cacheKey: 'custom-key' }); + }) + .on('cycle', (event) => { + console.log(String(event.target)); + }) + .on('complete', onComplete) + .run({ async: true }); diff --git a/test/benchmarks/concurrent.bench.jsx b/test/benchmarks/concurrent.bench.jsx new file mode 100644 index 00000000..05d3667e --- /dev/null +++ b/test/benchmarks/concurrent.bench.jsx @@ -0,0 +1,175 @@ +// This is just a raw comparison benchmark for the `useFetcher` hook +// against the `useSWR` hook from the SWR library +// and against the `useQuery` hook from React Query. + +// To run this benchmark, use the following command: +// npx tsx test/benchmarks/concurrent.bench.jsx +// Remember to install the necessary dependencies first: +// npm install -D @tanstack/react-query swr +import { JSDOM } from 'jsdom'; + +// Setup jsdom environment before React & RTL imports +const dom = new JSDOM(''); +global.window = dom.window; +global.document = dom.window.document; +global.HTMLElement = dom.window.HTMLElement; +global.Node = dom.window.Node; +global.navigator = dom.window.navigator; +global.getComputedStyle = dom.window.getComputedStyle; +global.document.body.innerHTML = '
'; + +import React from 'react'; +import Benchmark from 'benchmark'; +import { onComplete } from './utils.mjs'; +import useSWR from 'swr'; +import { + QueryClient, + QueryClientProvider, + useQuery, +} from '@tanstack/react-query'; + +import { mockFetchResponse } from '../utils/mockFetchResponse'; + +import { useFetcher } from '../../src/react/index'; // Ensure this path is correct for your setup +// import { useFetcher } from '../../dist/react/index.mjs'; // Ensure this path is correct for your setup +import { cleanup, render, getByTestId, waitFor } from '@testing-library/react'; + +const RUNS = 100; +const fetcher = (url) => fetch(url).then((res) => res.json()); + +// Mock i different endpoints +for (let i = 0; i < RUNS; i++) { + mockFetchResponse('/api/perf-' + i, { + status: 200, + ok: true, + body: { + userId: 1, + id: i, + title: 'Test Title', + body: 'Test Body', + }, + }); +} + +function ManySWRComponents() { + const requests = Array.from({ length: RUNS }, (_, i) => { + const response = useSWR('/api/perf-' + i, fetcher, {}); + + return response; + }); + + return ( +
+ {requests.filter((r) => r.data).length} loaded +
+ ); +} + +function ManyReactQueryComponents() { + const requests = Array.from({ length: RUNS }, (_, i) => { + const response = useQuery({ + queryKey: ['/api/perf-' + i], + queryFn: fetcher.bind(null, '/api/perf-' + i), + }); + + return response; + }); + + return ( +
+ {requests.filter((r) => r.data).length} loaded +
+ ); +} + +function ManyFetcherComponents() { + const requests = Array.from({ length: RUNS }, (_, i) => { + const response = useFetcher('/api/perf-' + i, { + fetcher, + }); + + return response; + }); + + return ( +
+ {requests.filter((r) => r.data).length} loaded +
+ ); +} + +let fetcherCount = 0; +let swrCount = 0; +let reactQueryCount = 0; +const settings = { + defer: true, +}; + +const fetcherBench = { + ...settings, + fn: async (deferred) => { + const { container } = render(); + await waitFor(async () => { + const el = getByTestId(container, 'many-requests'); + fetcherCount += Number(el.textContent?.match(/\d+/)?.[0] || 0); + }); + cleanup(); + deferred.resolve(); + }, +}; + +const swrBench = { + ...settings, + fn: async (deferred) => { + const { container } = render(); + await waitFor(async () => { + const el = getByTestId(container, 'many-requests'); + swrCount += Number(el?.textContent?.match(/\d+/)?.[0] || 0); + }); + cleanup(); + deferred.resolve(); + }, +}; + +const queryClient = new QueryClient(); + +const reactQueryBench = { + ...settings, + fn: async (deferred) => { + const { container } = render( + + + , + ); + await waitFor(async () => { + const el = getByTestId(container, 'many-requests'); + reactQueryCount += Number(el?.textContent?.match(/\d+/)?.[0] || 0); + }); + cleanup(); + deferred.resolve(); + }, +}; + +const suite = new Benchmark.Suite(); + +suite + .add('useFetcher (concurrent)', fetcherBench) + .add('useSWR (concurrent)', swrBench) + .add('useQuery (concurrent)', reactQueryBench) + .on('start', function () { + console.log( + `Time for ${RUNS} concurrent requests (End-to-end data load, UI update, cache):`, + ); + }) + .on('cycle', function (event) { + console.log(String(event.target)); + }) + .on('complete', function () { + onComplete.call(this); + console.log( + `Total requests: ${RUNS}, useFetcher calls: ${fetcherCount}, useSWR calls: ${swrCount}, useQuery calls: ${reactQueryCount}`, + ); + + process.exit(0); + }) + .run({ async: true }); diff --git a/test/benchmarks/fetchf.bench.mjs b/test/benchmarks/fetchf.bench.mjs new file mode 100644 index 00000000..ed59701c --- /dev/null +++ b/test/benchmarks/fetchf.bench.mjs @@ -0,0 +1,54 @@ +// To run this benchmark, use the following command: +// npx tsx test/benchmarks/generic.bench.mjs +import Benchmark from 'benchmark'; +import { fetchf } from '../../dist/browser/index.mjs'; +import { onComplete } from './utils.mjs'; + +// Mock fetch for consistent benchmark results +const post = { + userId: 1, + id: 1, + title: 'Sample Post', + body: 'This is a test post content', +}; + +global.fetch = () => + Promise.resolve({ + status: 200, + ok: true, + body: post, + json: () => Promise.resolve(post), + }); + +const suite = new Benchmark.Suite(); + +// Test scenarios +const simpleRequest = () => fetchf('https://api.example.com/posts/1'); + +const requestWithConfig = () => + fetchf('https://api.example.com/posts/1', { + strategy: 'softFail', + timeout: 5000, + }); + +const requestWithCache = () => + fetchf('https://api.example.com/posts/1', { + cacheTime: 300, + strategy: 'softFail', + }); + +suite + .add('Simple fetchf request', () => { + return simpleRequest(); + }) + .add('fetchf with config options', () => { + return requestWithConfig(); + }) + .add('fetchf with caching enabled', () => { + return requestWithCache(); + }) + .on('cycle', (event) => { + console.log(String(event.target)); + }) + .on('complete', onComplete) + .run({ async: true }); diff --git a/test/benchmarks/object-merge.bench.mjs b/test/benchmarks/object-merge.bench.mjs new file mode 100644 index 00000000..390432eb --- /dev/null +++ b/test/benchmarks/object-merge.bench.mjs @@ -0,0 +1,30 @@ +// To run this benchmark, use the following command: +// npx tsx test/benchmarks/object-merge.bench.mjs +import Benchmark from 'benchmark'; +import { onComplete } from './utils.mjs'; + +const obj1 = { a: 1, b: 2, c: 3 }; +const obj2 = { d: 4, e: 5, f: 6 }; +const obj3 = { g: 7, h: 8, i: 9 }; + +const suite = new Benchmark.Suite(); + +suite + .add('Spread operator', () => { + return { ...obj1, ...obj2, ...obj3 }; + }) + .add('Object.assign', () => { + return Object.assign({}, obj1, obj2, obj3); + }) + .add('Nested spread', () => { + return { ...{ ...obj1, ...obj2 }, ...obj3 }; + }) + .add('Sequential spread', () => { + const merged1 = { ...obj1, ...obj2 }; + return { ...merged1, ...obj3 }; + }) + .on('cycle', (event) => { + console.log(String(event.target)); + }) + .on('complete', onComplete) + .run(); diff --git a/test/benchmarks/operators.bench.mjs b/test/benchmarks/operators.bench.mjs new file mode 100644 index 00000000..a33ac3d5 --- /dev/null +++ b/test/benchmarks/operators.bench.mjs @@ -0,0 +1,22 @@ +// To run this benchmark, use the following command: +// npx tsx test/benchmarks/operators.bench.mjs +import Benchmark from 'benchmark'; +import { onComplete } from './utils.mjs'; + +const suite = new Benchmark.Suite(); + +const obj1 = undefined; +const obj2 = 1; + +suite + .add('|| operator', () => { + return obj1 || obj2; + }) + .add('?? operator', () => { + return obj1 ?? obj2; + }) + .on('cycle', (event) => { + console.log(String(event.target)); + }) + .on('complete', onComplete) + .run(); diff --git a/test/benchmarks/rq.bench.jsx b/test/benchmarks/rq.bench.jsx new file mode 100644 index 00000000..6e1341bb --- /dev/null +++ b/test/benchmarks/rq.bench.jsx @@ -0,0 +1,108 @@ +// This is just a raw comparison benchmark for the `useFetcher` hook +// against the `useQuery` hook from React Query (cold starts). + +// To run this benchmark, use the following command: +// npx tsx test/benchmarks/rq.bench.jsx +import { JSDOM } from 'jsdom'; +import React from 'react'; + +// Setup jsdom environment before React & RTL imports +const dom = new JSDOM(''); +global.window = dom.window; +global.document = dom.window.document; + +import Benchmark from 'benchmark'; +import { onComplete } from './utils.mjs'; +import { + QueryClient, + QueryClientProvider, + useQuery, +} from '@tanstack/react-query'; + +import { useFetcher } from '../../src/react/index'; // Ensure this path is correct for your setup +import { act, cleanup, render } from '@testing-library/react'; + +// Mock fetch response before running benchmarks +global.fetch = () => + // @ts-expect-error This is a mock implementation + Promise.resolve({ + status: 200, + ok: true, + body: { + userId: 1, + id: 1, + title: 'Test Title', + body: 'Test Body', + }, + }); +const fetcher = (url) => fetch(url).then(async (res) => await res.json()); +let j = 0; +let k = 0; + +function FetcherComponent() { + j++; + const { data } = useFetcher('https://localhost/posts/' + j, { fetcher }); + return
{data} loaded
; +} + +function ReactQueryComponent() { + k++; + const { data } = useQuery({ + queryKey: [`https://localhost/posts/${k}`], + queryFn: fetcher.bind(null, 'https://localhost/posts/' + k), + }); + return
{data} loaded
; +} + +const suite = new Benchmark.Suite(); +const settings = { + defer: true, +}; + +const fetcherBench = { + ...settings, + fn: async (deferred) => { + // Render the component inside benchmarked function + await act(async () => { + const { unmount } = render(); + unmount(); + }); + + cleanup(); + + deferred.resolve(); + }, +}; + +const queryClient = new QueryClient(); + +const reactQueryBench = { + ...settings, + fn: async (deferred) => { + // Render the component inside benchmarked function + await act(async () => { + const { unmount } = render( + + + , + ); + + unmount(); + }); + + cleanup(); + deferred.resolve(); + }, +}; + +suite + .add('useFetcher', fetcherBench) + .add('useQuery', reactQueryBench) + .on('start', function () { + console.log('Time per single mount/fetch/unmount:'); + }) + .on('cycle', function (event) { + console.log(String(event.target)); + }) + .on('complete', onComplete) + .run({ async: true }); diff --git a/test/benchmarks/swr.bench.jsx b/test/benchmarks/swr.bench.jsx new file mode 100644 index 00000000..429f48fe --- /dev/null +++ b/test/benchmarks/swr.bench.jsx @@ -0,0 +1,92 @@ +// This is just a raw comparison benchmark for the `useFetcher` hook +// against the `useSWR` hook from the SWR library (cold starts). + +// To run this benchmark, use the following command: +// npx tsx test/benchmarks/swr.bench.jsx +import { JSDOM } from 'jsdom'; +import React from 'react'; + +// Setup jsdom environment before React & RTL imports +const dom = new JSDOM(''); +global.window = dom.window; +global.document = dom.window.document; + +import Benchmark from 'benchmark'; +import { onComplete } from './utils.mjs'; +import useSWR from 'swr'; +import { useFetcher } from '../../src/react/index'; // Ensure this path is correct for your setup +import { act, cleanup, render } from '@testing-library/react'; + +// Mock fetch response before running benchmarks +global.fetch = () => + // @ts-expect-error This is a mock implementation + Promise.resolve({ + status: 200, + ok: true, + body: { + userId: 1, + id: 1, + title: 'Test Title', + body: 'Test Body', + }, + }); +const fetcher = (url) => fetch(url).then(async (res) => await res.json()); +let i = 0; +let j = 0; +function SWRComponent() { + i++; + const { data } = useSWR('https://localhost/posts/' + i, fetcher, {}); + return
{data} loaded
; +} + +function FetcherComponent() { + j++; + const { data } = useFetcher('https://localhost/posts/' + j, { fetcher }); + return
{data} loaded
; +} + +const suite = new Benchmark.Suite(); +const settings = { + defer: true, +}; + +const swrBench = { + ...settings, + fn: async (deferred) => { + // Render the component inside benchmarked function + await act(async () => { + const { unmount } = render(); + unmount(); + }); + cleanup(); + + deferred.resolve(); + }, +}; + +const fetcherBench = { + ...settings, + fn: async (deferred) => { + // Render the component inside benchmarked function + await act(async () => { + const { unmount } = render(); + unmount(); + }); + + cleanup(); + + deferred.resolve(); + }, +}; + +suite + .add('useFetcher', fetcherBench) + .add('useSWR', swrBench) + .on('start', function () { + console.log('Time per single mount/fetch/unmount:'); + }) + .on('cycle', function (event) { + console.log(String(event.target)); + }) + .on('complete', onComplete) + .run({ async: true }); diff --git a/test/benchmarks/utils.mjs b/test/benchmarks/utils.mjs new file mode 100644 index 00000000..199b388f --- /dev/null +++ b/test/benchmarks/utils.mjs @@ -0,0 +1,41 @@ +import chalk from 'chalk'; + +function onComplete() { + // @ts-expect-error this is a Benchmark.js context + const results = this.map((bench) => ({ + name: bench.name, + hz: bench.hz, + rme: bench.stats.rme, + samples: bench.stats.sample.length, + })); + + // @ts-expect-error this is a Benchmark.js context + results.sort((a, b) => b.hz - a.hz); + + console.log(chalk.bold('\nBenchmark results:')); + // @ts-expect-error this is a Benchmark.js context + results.forEach((r) => { + const name = chalk.yellow(r.name); + const ops = chalk.green( + `${r.hz.toLocaleString('pl-PL', { maximumFractionDigits: 0 })} ops/sec`, + ); + const error = chalk.red(`Âą${r.rme.toFixed(2)}%`); + const samples = chalk.blue(`(${r.samples} runs sampled)`); + + console.log(`${name}: ${ops} ${error} ${samples}`); + }); + + const fastest = results[0]; + console.log(chalk.bold.green(`\nFastest is ${fastest.name}`)); + + // @ts-expect-error this is a Benchmark.js context + results.slice(1).forEach((r) => { + const pctSlower = ((fastest.hz - r.hz) / fastest.hz) * 100; + const slowerText = chalk.red(`${pctSlower.toFixed(2)}% slower`); + console.log( + `${chalk.yellow(r.name)} is ${slowerText} than ${chalk.green(fastest.name)}`, + ); + }); +} + +export { onComplete }; diff --git a/test/cache-manager.spec.ts b/test/cache-manager.spec.ts index dbe8a6e7..d88c2ed3 100644 --- a/test/cache-manager.spec.ts +++ b/test/cache-manager.spec.ts @@ -2,16 +2,21 @@ import { generateCacheKey, getCache, setCache, - revalidate, deleteCache, - mutate, getCachedResponse, + mutate, + pruneCache, + IMMEDIATE_DISCARD_CACHE_TIME, } from '../src/cache-manager'; -import { fetchf } from '../src/index'; +import { RequestConfig } from '../src/index'; import * as hashM from '../src/hash'; -import * as utils from '../src/utils'; +import * as pubsubManager from '../src/pubsub-manager'; +import * as revalidatorManager from '../src/revalidator-manager'; +import { clearAllTimeouts } from '../src/timeout-wheel'; jest.mock('../src/index'); +jest.mock('../src/pubsub-manager'); +jest.mock('../src/revalidator-manager'); describe('Cache Manager', () => { beforeAll(() => { @@ -19,10 +24,15 @@ describe('Cache Manager', () => { ...global.console, error: jest.fn(), }; + jest.useFakeTimers(); }); beforeEach(() => { + pruneCache(); + clearAllTimeouts(); jest.clearAllMocks(); + jest.runAllTimers(); + jest.clearAllTimers(); }); describe('generateCacheKey', () => { @@ -37,9 +47,7 @@ describe('Cache Manager', () => { 'Accept-Encoding': 'gzip, deflate, br', }, }); - expect(key).toContain( - 'GET|httpsapiexamplecomdata|corssame-origindefaultfollowaboutclient|Accept-EncodinggzipdeflatebrContent-Typeapplicationjson|', - ); + expect(key).toContain('GET|httpsapiexamplecomdata'); }); it('should generate a cache key for basic GET request with empty url', () => { @@ -60,14 +68,13 @@ describe('Cache Manager', () => { }), } as never); - expect(key).toContain( - 'accept-encodinggzipdeflatebrcontent-typeapplicationjson', - ); + expect(key).toContain('1306150308'); }); it('should generate a cache key for basic GET request with sorted hashed headers', () => { const key = generateCacheKey({ method: 'GET', + url: 'https://api.example.com/data', headers: new Headers({ 'Content-Type': 'application/json', 'Accept-Encoding': 'gzip, deflate, br', @@ -76,32 +83,19 @@ describe('Cache Manager', () => { } as never); expect(key).toContain( - 'GET||corssame-origindefaultfollowaboutclient|1910039066|', + 'GET|httpsapiexamplecomdata|same-origin|1306150308', ); }); - it('should return an empty string if cache is reload', () => { - const key = generateCacheKey({ - url, - cache: 'reload', - }); - expect(key).toBe(''); - }); - it('should generate a cache key with sorted headers', () => { - const shallowSerialize = jest.spyOn(utils, 'shallowSerialize'); - const key = generateCacheKey({ url, method: 'POST', headers: { 'Content-Type': 'application/json' }, }); expect(key).toContain( - 'POST|httpsapiexamplecomdata|corssame-origindefaultfollowaboutclient|Content-Typeapplicationjson|', + 'POST|httpsapiexamplecomdata|same-origin|395078312|', ); - expect(shallowSerialize).toHaveBeenCalledWith({ - 'Content-Type': 'application/json', - }); }); it('should hash the longer stringified body if provided', () => { @@ -114,7 +108,7 @@ describe('Cache Manager', () => { }); expect(spy).toHaveBeenCalled(); expect(key).toContain( - 'POST|httpsapiexamplecomdata|corssame-origindefaultfollowaboutclient||655859486', + 'POST|httpsapiexamplecomdata|same-origin||655859486', ); }); @@ -128,7 +122,7 @@ describe('Cache Manager', () => { }); expect(spy).toHaveBeenCalled(); expect(key).toContain( - 'POST|httpsapiexamplecomdata|corssame-origindefaultfollowaboutclient||-1171129837', + 'POST|httpsapiexamplecomdata|same-origin||-1171129837', ); }); @@ -142,7 +136,7 @@ describe('Cache Manager', () => { }); expect(spy).not.toHaveBeenCalled(); expect(key).toContain( - 'POST|httpsapiexamplecomdata|corssame-origindefaultfollowaboutclient||nameAlice', + 'POST|httpsapiexamplecomdata|same-origin||nameAlice', ); }); @@ -156,7 +150,7 @@ describe('Cache Manager', () => { body: formData, }); expect(key).toContain( - 'POST|httpsapiexamplecomdata|corssame-origindefaultfollowaboutclient||1870802307', + 'POST|httpsapiexamplecomdata|same-origin||1870802307', ); }); @@ -168,7 +162,7 @@ describe('Cache Manager', () => { body: blob, }); expect(key).toContain( - 'POST|httpsapiexamplecomdata|corssame-origindefaultfollowaboutclient||BF4textplain', + 'POST|httpsapiexamplecomdata|same-origin||BF4textplain', ); }); @@ -188,9 +182,7 @@ describe('Cache Manager', () => { method: 'POST', body: 10, }); - expect(key).toContain( - 'POST|httpsapiexamplecomdata|corssame-origindefaultfollowaboutclient||10', - ); + expect(key).toContain('POST|httpsapiexamplecomdata|same-origin||10'); }); it('should handle Array body', () => { @@ -200,9 +192,7 @@ describe('Cache Manager', () => { method: 'POST', body: arrayBody, }); - expect(key).toContain( - 'POST|httpsapiexamplecomdata|corssame-origindefaultfollowaboutclient||011223', - ); + expect(key).toContain('POST|httpsapiexamplecomdata|same-origin||011223'); }); it('should handle Object body and sort properties', () => { @@ -213,40 +203,21 @@ describe('Cache Manager', () => { body: objectBody, }); - expect(key).toContain( - 'POST|httpsapiexamplecomdata|corssame-origindefaultfollowaboutclient||a1b2', - ); + expect(key).toContain('POST|httpsapiexamplecomdata|same-origin||a1b2'); }); }); - describe('getCache', () => { + describe('getCacheEntry', () => { afterEach(() => { deleteCache('key'); }); it('should return cache entry if not expired', () => { - setCache('key', { data: 'test' }, false); - const result = getCache('key', 0); + setCache('key', { data: 'test' }); + const result = getCache('key'); expect(result).not.toBeNull(); expect(result?.data).toEqual({ data: 'test' }); }); - - it('should return null and delete cache if expired', () => { - setCache('key', { data: 'test' }, false); - const result = getCache('key', -1); - expect(result).toBeNull(); - }); - - it('should return null if no cache entry exists', () => { - const result = getCache('nonExistentKey', 60); - expect(result).toBeNull(); - }); - - it('should delete expired cache entry', () => { - setCache('key', { data: 'test' }, false); - deleteCache('key'); - expect(getCache('key', 60)).toBe(null); - }); }); describe('setCache', () => { @@ -256,63 +227,16 @@ describe('Cache Manager', () => { it('should set cache with proper data', () => { const data = { foo: 'bar' }; - setCache('key', data); - const entry = getCache('key', 60); + setCache('key', data, 60); + const entry = getCache('key'); expect(entry?.data).toEqual(data); - expect(entry?.isLoading).toBe(false); - }); - - it('should handle isLoading state', () => { - setCache('key', { foo: 'bar' }, true); - const entry = getCache('key', 60); - expect(entry?.isLoading).toBe(true); }); it('should set timestamp when caching data', () => { const timestampBefore = Date.now(); - setCache('key', { foo: 'bar' }); - const entry = getCache('key', 60); - expect(entry?.timestamp).toBeGreaterThanOrEqual(timestampBefore); - }); - }); - - describe('revalidate', () => { - afterEach(() => { - deleteCache('key'); - }); - - it('should fetch fresh data and update cache', async () => { - const mockResponse = { data: 'newData' }; - (fetchf as jest.Mock).mockResolvedValue(mockResponse); - - await revalidate('key', { url: 'https://api.example.com' }); - const entry = getCache('key', 60); - expect(entry?.data).toEqual(mockResponse); - }); - - it('should handle fetch errors during revalidation', async () => { - const errorMessage = 'Fetch failed'; - (fetchf as jest.Mock).mockRejectedValue(new Error(errorMessage)); - - await expect( - revalidate('key', { url: 'https://api.example.com' }), - ).rejects.toThrow(errorMessage); - const entry = getCache('key', 60); - expect(entry?.data).toBeUndefined(); - }); - - it('should not update cache if revalidation fails', async () => { - const errorMessage = 'Fetch failed'; - const oldData = { data: 'oldData' }; - - (fetchf as jest.Mock).mockRejectedValue(new Error(errorMessage)); - setCache('key', oldData); - - await expect( - revalidate('key', { url: 'https://api.example.com' }), - ).rejects.toThrow(errorMessage); - const entry = getCache('key', 60); - expect(entry?.data).toEqual(oldData); + setCache('key', { foo: 'bar' }, 60); + const entry = getCache('key'); + expect(entry?.time).toBeGreaterThanOrEqual(timestampBefore); }); }); @@ -320,52 +244,45 @@ describe('Cache Manager', () => { it('should delete cache entry', () => { setCache('key', { data: 'test' }); deleteCache('key'); - expect(getCache('key', 60)).toBe(null); + expect(getCache('key')).toBeUndefined(); }); it('should do nothing if cache key does not exist', () => { deleteCache('nonExistentKey'); - expect(getCache('nonExistentKey', 60)).toBe(null); + expect(getCache('nonExistentKey')).toBeUndefined(); }); - }); - describe('mutate', () => { - it('should mutate cache entry with new data', () => { - setCache('key', { data: 'oldData' }); - mutate('key', { url: 'https://api.example.com' }, { data: 'newData' }); - const entry = getCache('key', 60); - expect(entry?.data).toEqual({ data: 'newData' }); - }); - - it('should revalidate after mutation if revalidateAfter is true', async () => { - const mockResponse = { data: 'newData' }; - (fetchf as jest.Mock).mockResolvedValue(mockResponse); - - await mutate( - 'key', - { url: 'https://api.example.com' }, - { data: 'mutatedData' }, - true, - ); - const entry = getCache('key', 60); - expect(entry?.data).toEqual(mockResponse); + it('should delete cache entry when removeExpired is false (default)', () => { + setCache('key', { data: 'test' }); + deleteCache('key', false); // Explicitly pass false + expect(getCache('key')).toBeUndefined(); }); - it('should not revalidate after mutation if revalidateAfter is false', async () => { - setCache('key', { data: 'oldData' }); - mutate( - 'key', - { url: 'https://api.example.com' }, - { data: 'newData' }, - false, - ); - expect(fetchf).not.toHaveBeenCalled(); + it('should not delete cache entry when removeExpired is true and entry is not expired', () => { + setCache('key', { data: 'test' }, 60); // Set with 60 second TTL + deleteCache('key', true); // Only delete if expired + expect(getCache('key')).not.toBe(null); + expect(getCache('key')?.data).toEqual({ data: 'test' }); + }); + + it('should delete cache entry when removeExpired is true and entry has IMMEDIATE_DISCARD_CACHE_TIME', () => { + setCache('key', { data: 'test' }, IMMEDIATE_DISCARD_CACHE_TIME); // No TTL = IMMEDIATE_DISCARD_CACHE_TIME + deleteCache('key', true); // Should delete since IMMEDIATE_DISCARD_CACHE_TIME entries are considered expired when removeExpired is true + jest.advanceTimersByTime(1000); // Fast-forward time to ensure cache is considered expired + expect(getCache('key')).toBeUndefined(); + }); + + it('should delete expired cache entry when removeExpired is true', () => { + // Create an entry that's already expired by using a past timestamp + setCache('key', { data: 'test' }, -5000); // Set with negative TTL to simulate expiration + deleteCache('key', true); + expect(getCache('key')).toBeUndefined(); }); }); describe('getCachedResponse', () => { const cacheKey = 'test-key'; - const fetcherConfig = { url: 'https://api.example.com' }; + const fetcherConfig = { url: 'https://api.example.com' } as RequestConfig; const cacheTime = 60; const responseObj = { data: 'cachedData' }; @@ -374,66 +291,165 @@ describe('Cache Manager', () => { }); it('should return cached response if available and not expired', () => { - setCache(cacheKey, responseObj); - const result = getCachedResponse( - cacheKey, - cacheTime, - undefined, - fetcherConfig, - ); + setCache(cacheKey, responseObj, cacheTime); + const result = getCachedResponse(cacheKey, cacheTime, fetcherConfig); expect(result).toEqual(responseObj); }); it('should return null if cacheKey is null', () => { - setCache(cacheKey, responseObj); - const result = getCachedResponse( - null, - cacheTime, - undefined, - fetcherConfig, - ); + setCache(cacheKey, responseObj, cacheTime); + const result = getCachedResponse(null, cacheTime, fetcherConfig); expect(result).toBeNull(); }); it('should return null if cacheTime is undefined', () => { setCache(cacheKey, responseObj); - const result = getCachedResponse( - cacheKey, - undefined, - undefined, - fetcherConfig, - ); + const result = getCachedResponse(cacheKey, undefined, fetcherConfig); expect(result).toBeNull(); }); it('should return null if cache is expired', () => { - setCache(cacheKey, responseObj); + setCache(cacheKey, responseObj, 1); + jest.advanceTimersByTime(2000); // Fast-forward time by 2 seconds // Simulate expiration by using negative cacheTime - const result = getCachedResponse(cacheKey, -1, undefined, fetcherConfig); + const result = getCachedResponse(cacheKey, 1, fetcherConfig); expect(result).toBeNull(); }); + it('should not return null and not delete cache if set to -1 (as long as it is used)', () => { + setCache(cacheKey, responseObj); + // Simulate expiration by using negative cacheTime + const result = getCachedResponse(cacheKey, -1, fetcherConfig); + expect(result).not.toBeNull(); + }); + it('should return null if cacheBuster returns true', () => { setCache(cacheKey, responseObj); - const cacheBuster = jest.fn().mockReturnValue(true); - const result = getCachedResponse( - cacheKey, - cacheTime, - cacheBuster, - fetcherConfig, - ); + fetcherConfig.cacheBuster = jest.fn().mockReturnValue(true); + const result = getCachedResponse(cacheKey, cacheTime, fetcherConfig); expect(result).toBeNull(); - expect(cacheBuster).toHaveBeenCalledWith(fetcherConfig); + expect(fetcherConfig.cacheBuster).toHaveBeenCalledWith(fetcherConfig); }); it('should return null if no cache entry exists', () => { + delete fetcherConfig.cacheBuster; const result = getCachedResponse( 'non-existent-key', cacheTime, - undefined, fetcherConfig, ); expect(result).toBeNull(); }); }); + + describe('mutate', () => { + const cacheKey = 'test-key'; + const initialData = { data: 'initial', time: Date.now() }; + const newData = { name: 'John', age: 30 }; + + afterEach(() => { + deleteCache(cacheKey); + jest.clearAllMocks(); + }); + + it('should do nothing if no key is provided', async () => { + const notifySubscribersSpy = jest.spyOn( + pubsubManager, + 'notifySubscribers', + ); + const revalidateSpy = jest.spyOn(revalidatorManager, 'revalidate'); + + await mutate('', newData); + + expect(notifySubscribersSpy).not.toHaveBeenCalled(); + expect(revalidateSpy).not.toHaveBeenCalled(); + }); + + it('should do nothing if cache entry does not exist', async () => { + const notifySubscribersSpy = jest.spyOn( + pubsubManager, + 'notifySubscribers', + ); + const revalidateSpy = jest.spyOn(revalidatorManager, 'revalidate'); + + await mutate('non-existent-key', newData); + + expect(notifySubscribersSpy).not.toHaveBeenCalled(); + expect(revalidateSpy).not.toHaveBeenCalled(); + }); + + it('should mutate cache and notify subscribers without revalidation', async () => { + setCache(cacheKey, initialData); + const notifySubscribersSpy = jest.spyOn( + pubsubManager, + 'notifySubscribers', + ); + const revalidateSpy = jest.spyOn(revalidatorManager, 'revalidate'); + + await mutate(cacheKey, newData); + + const updatedCache = getCache(cacheKey); + expect(updatedCache?.data).toEqual({ + ...initialData, + data: newData, + }); + expect(notifySubscribersSpy).toHaveBeenCalledWith(cacheKey, { + ...initialData, + data: newData, + }); + expect(revalidateSpy).not.toHaveBeenCalled(); + }); + + it('should mutate cache, notify subscribers and revalidate when revalidate setting is true', async () => { + setCache(cacheKey, initialData); + const notifySubscribersSpy = jest.spyOn( + pubsubManager, + 'notifySubscribers', + ); + const revalidateSpy = jest.spyOn(revalidatorManager, 'revalidate'); + + await mutate(cacheKey, newData, { refetch: true }); + + const updatedCache = getCache(cacheKey); + expect(updatedCache?.data).toEqual({ + ...initialData, + data: newData, + time: expect.any(Number), + }); + expect(notifySubscribersSpy).toHaveBeenCalledWith(cacheKey, { + ...initialData, + data: newData, + time: expect.any(Number), + }); + expect(revalidateSpy).toHaveBeenCalledWith(cacheKey); + }); + + it('should not revalidate when revalidate setting is false', async () => { + setCache(cacheKey, initialData); + const notifySubscribersSpy = jest.spyOn( + pubsubManager, + 'notifySubscribers', + ); + const revalidateSpy = jest.spyOn(revalidatorManager, 'revalidate'); + + await mutate(cacheKey, newData, { refetch: false }); + + expect(notifySubscribersSpy).toHaveBeenCalled(); + expect(revalidateSpy).not.toHaveBeenCalled(); + }); + + it('should handle mutation with undefined settings', async () => { + setCache(cacheKey, initialData); + const notifySubscribersSpy = jest.spyOn( + pubsubManager, + 'notifySubscribers', + ); + const revalidateSpy = jest.spyOn(revalidatorManager, 'revalidate'); + + await mutate(cacheKey, newData, undefined); + + expect(notifySubscribersSpy).toHaveBeenCalled(); + expect(revalidateSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/test/config-handler.spec.ts b/test/config-handler.spec.ts index 5f32bcaa..8db40140 100644 --- a/test/config-handler.spec.ts +++ b/test/config-handler.spec.ts @@ -1,7 +1,7 @@ -import { buildConfig } from '../src/config-handler'; +import { buildFetcherConfig } from '../src/config-handler'; import { GET, CONTENT_TYPE } from '../src/constants'; -describe('buildConfig() with native fetch()', () => { +describe('buildFetcherConfig() with native fetch()', () => { const contentTypeValue = 'application/json;charset=utf-8'; const headers = { Accept: 'application/json, text/plain, */*', @@ -10,13 +10,13 @@ describe('buildConfig() with native fetch()', () => { }; it('should not differ when the same request is made', () => { - const result = buildConfig('https://example.com/api', { + const result = buildFetcherConfig('https://example.com/api', { method: 'GET', data: { foo: 'bar' }, baseURL: 'abc', }); - const result2 = buildConfig('https://example.com/api', { + const result2 = buildFetcherConfig('https://example.com/api', { method: 'GET', data: { foo: 'bar' }, baseURL: 'abc', @@ -26,7 +26,7 @@ describe('buildConfig() with native fetch()', () => { }); it('should handle GET requests correctly', () => { - const result = buildConfig('https://example.com/api', { + const result = buildFetcherConfig('https://example.com/api', { method: 'GET', headers, params: { foo: 'bar' }, @@ -40,7 +40,7 @@ describe('buildConfig() with native fetch()', () => { }); it('should handle POST requests correctly', () => { - const result = buildConfig('https://example.com/api', { + const result = buildFetcherConfig('https://example.com/api', { method: 'POST', data: { foo: 'bar' }, headers, @@ -55,7 +55,7 @@ describe('buildConfig() with native fetch()', () => { }); it('should handle PUT requests correctly', () => { - const result = buildConfig('https://example.com/api', { + const result = buildFetcherConfig('https://example.com/api', { method: 'PUT', data: { foo: 'bar' }, headers, @@ -70,7 +70,7 @@ describe('buildConfig() with native fetch()', () => { }); it('should handle DELETE requests correctly', () => { - const result = buildConfig('https://example.com/api', { + const result = buildFetcherConfig('https://example.com/api', { method: 'DELETE', data: { foo: 'bar' }, headers, @@ -94,7 +94,7 @@ describe('buildConfig() with native fetch()', () => { ...{ 'X-CustomHeader': 'Some token' }, }; - const result = buildConfig('https://example.com/api', { + const result = buildFetcherConfig('https://example.com/api', { ...mergedConfig, method: 'POST', data: { additional: 'info' }, @@ -113,7 +113,7 @@ describe('buildConfig() with native fetch()', () => { }); it('should handle empty data and config', () => { - const result = buildConfig('https://example.com/api', { + const result = buildFetcherConfig('https://example.com/api', { method: 'POST', data: null, }); @@ -126,7 +126,7 @@ describe('buildConfig() with native fetch()', () => { }); it('should handle data as string', () => { - const result = buildConfig('https://example.com/api', { + const result = buildFetcherConfig('https://example.com/api', { method: 'POST', data: 'rawData', }); @@ -139,7 +139,7 @@ describe('buildConfig() with native fetch()', () => { }); it('should correctly append query params for GET-alike methods', () => { - const result = buildConfig('https://example.com/api', { + const result = buildFetcherConfig('https://example.com/api', { method: 'HEAD', params: { foo: [1, 2] }, }); @@ -151,7 +151,7 @@ describe('buildConfig() with native fetch()', () => { }); it('should handle POST with query params in config', () => { - const result = buildConfig('https://example.com/api', { + const result = buildFetcherConfig('https://example.com/api', { method: 'POST', data: { additional: 'info' }, params: { foo: 'bar' }, @@ -165,7 +165,7 @@ describe('buildConfig() with native fetch()', () => { }); it('should append credentials if flag is used', () => { - const result = buildConfig('https://example.com/api', { + const result = buildFetcherConfig('https://example.com/api', { method: 'POST', data: null, withCredentials: true, @@ -180,7 +180,7 @@ describe('buildConfig() with native fetch()', () => { }); it('should not append query params to POST requests if body is set as data', () => { - const result = buildConfig('https://example.com/api', { + const result = buildFetcherConfig('https://example.com/api', { method: 'POST', data: { foo: 'bar' }, }); @@ -193,7 +193,7 @@ describe('buildConfig() with native fetch()', () => { }); it('should not append body nor data to GET requests', () => { - const result = buildConfig('https://example.com/api', { + const result = buildFetcherConfig('https://example.com/api', { method: 'GET', data: { foo: 'bar' }, body: { additional: 'info' }, @@ -216,31 +216,44 @@ describe('request() Content-Type', () => { jest.clearAllMocks(); }); - const headers = { - Accept: 'application/json, text/plain, */*', - 'Accept-Encoding': 'gzip, deflate, br', - }; - describe.each([ { method: 'DELETE', body: undefined, expectContentType: false }, - { method: 'PUT', body: undefined, expectContentType: false }, + { method: 'DELETE', body: null, expectContentType: false }, { method: 'DELETE', body: { foo: 'bar' }, expectContentType: true }, + { method: 'PUT', body: undefined, expectContentType: false }, + { method: 'PUT', body: null, expectContentType: false }, { method: 'PUT', body: { foo: 'bar' }, expectContentType: true }, - { method: 'POST', body: undefined, expectContentType: true }, - { method: GET, body: undefined, expectContentType: true }, + { method: 'POST', body: undefined, expectContentType: false }, + { method: 'POST', body: null, expectContentType: false }, + { method: 'POST', body: { foo: 'bar' }, expectContentType: true }, + { method: 'PATCH', body: undefined, expectContentType: false }, + { method: 'PATCH', body: null, expectContentType: false }, + { method: 'PATCH', body: { foo: 'bar' }, expectContentType: true }, + { method: GET, body: undefined, expectContentType: false }, + { method: GET, body: null, expectContentType: false }, + { method: GET, body: { foo: 'bar' }, expectContentType: false }, + { method: 'HEAD', body: undefined, expectContentType: false }, + { method: 'HEAD', body: null, expectContentType: false }, + { method: 'HEAD', body: { foo: 'bar' }, expectContentType: false }, ])( '$method request with body: $body', ({ method, body, expectContentType }) => { it( expectContentType - ? 'should set Content-Type when body is provided or method requires it' - : 'should not set Content-Type when no body is provided for DELETE or PUT', + ? 'should set Content-Type when body is provided and method allows it' + : 'should not set Content-Type when no body is provided or method does not allow it', () => { - const result = buildConfig(apiUrl, { + const headers = { + Accept: 'application/json, text/plain, */*', + 'Accept-Encoding': 'gzip, deflate, br', + }; + const cfg = { method, body, headers, - }); + }; + + const result = buildFetcherConfig(apiUrl, cfg); if (expectContentType) { expect(result.headers).toHaveProperty( CONTENT_TYPE, @@ -259,7 +272,7 @@ describe('request() Content-Type', () => { (method) => { it(`should keep custom Content-Type for ${method} method`, () => { const customContentType = 'application/x-www-form-urlencoded'; - const result = buildConfig(apiUrl, { + const result = buildFetcherConfig(apiUrl, { method, headers: { 'Content-Type': customContentType }, }); diff --git a/test/queue-manager.spec.ts b/test/inflight-manager.spec.ts similarity index 77% rename from test/queue-manager.spec.ts rename to test/inflight-manager.spec.ts index eeaf8c09..116f4c25 100644 --- a/test/queue-manager.spec.ts +++ b/test/inflight-manager.spec.ts @@ -1,42 +1,59 @@ import { - queueRequest, - removeRequestFromQueue, + markInFlight as _markInFlight, + abortRequest, getController, -} from '../src/queue-manager'; +} from '../src/inflight-manager'; const createKey = (url: string) => url; +const markInFlight = async ( + key: string, + url: string, + timeout: number | undefined, + dedupeTime: number = 0, + isCancellable: boolean = false, + isTimeoutEnabled: boolean = true, +): Promise => { + return _markInFlight( + key, + url, + timeout, + dedupeTime, + isCancellable, + isTimeoutEnabled, + ); +}; -describe('Request Queue Manager', () => { +describe('InFlight Request Manager', () => { beforeAll(() => { jest.useFakeTimers(); }); it('should add and retrieve a request correctly', async () => { const key = createKey('https://example.com'); - const controller = await queueRequest(key, 'https://example.com', 1000); + const controller = await markInFlight(key, 'https://example.com', 1000); const retrievedController = await getController(key); expect(retrievedController).toBe(controller); }); it('should remove a request from the queue', async () => { const key = createKey('https://example.com'); - await queueRequest(key, 'https://example.com', 1000); - await removeRequestFromQueue(key); + await markInFlight(key, 'https://example.com', 1000); + await abortRequest(key); const retrievedController = await getController(key); expect(retrievedController).toBeUndefined(); }); it('should handle removing a non-existent request', async () => { const key = createKey('https://example.com'); - await expect(removeRequestFromQueue(key)).resolves.not.toThrow(); + await expect(abortRequest(key)).resolves.not.toThrow(); }); it('should handle multiple concurrent requests correctly', async () => { const key1 = createKey('https://example1.com'); const key2 = createKey('https://example2.com'); const [controller1, controller2] = await Promise.all([ - queueRequest(key1, 'https://example1.com', 1000), - queueRequest(key2, 'https://example2.com', 1000), + markInFlight(key1, 'https://example1.com', 1000), + markInFlight(key2, 'https://example2.com', 1000), ]); const [retrievedController1, retrievedController2] = await Promise.all([ getController(key1), @@ -44,16 +61,16 @@ describe('Request Queue Manager', () => { ]); expect(retrievedController1).toBe(controller1); expect(retrievedController2).toBe(controller2); - await removeRequestFromQueue(key1); - await removeRequestFromQueue(key2); + await abortRequest(key1); + await abortRequest(key2); }); it('should handle concurrent requests with different configurations separately', async () => { const key1 = createKey('https://example.com/a'); const key2 = createKey('https://example.com/b'); const [controller1, controller2] = await Promise.all([ - queueRequest(key1, 'https://example.com/a', 2000), - queueRequest(key2, 'https://example.com/b', 2000), + markInFlight(key1, 'https://example.com/a', 2000), + markInFlight(key2, 'https://example.com/b', 2000), ]); jest.advanceTimersByTime(2000); expect(controller1).toBeDefined(); @@ -64,17 +81,17 @@ describe('Request Queue Manager', () => { it('should abort request due to timeout and remove it from queue', async () => { const key = createKey('https://example.com'); const timeout = 1000; - await queueRequest(key, 'https://example.com', timeout); + await markInFlight(key, 'https://example.com', timeout); jest.advanceTimersByTime(timeout); const controller = await getController(key); expect(controller).toBeUndefined(); - await removeRequestFromQueue(key); + await abortRequest(key); }); it('should queue multiple operations on the same request key correctly', async () => { const key = createKey('https://example.com'); - const firstRequestPromise = queueRequest(key, 'https://example.com', 2000); - const secondRequestPromise = queueRequest(key, 'https://example.com', 2000); + const firstRequestPromise = markInFlight(key, 'https://example.com', 2000); + const secondRequestPromise = markInFlight(key, 'https://example.com', 2000); jest.advanceTimersByTime(500); expect(await getController(key)).toBeTruthy(); jest.advanceTimersByTime(1500); @@ -86,7 +103,7 @@ describe('Request Queue Manager', () => { it('should clear timeout and abort request on removal', async () => { const key = createKey('https://example.com'); - await queueRequest(key, 'https://example.com', 1000); + await markInFlight(key, 'https://example.com', 1000); jest.advanceTimersByTime(1500); const retrievedController = await getController(key); expect(retrievedController).toBeUndefined(); @@ -94,23 +111,23 @@ describe('Request Queue Manager', () => { it('should deduplicate same requests within dedupeTime', async () => { const key = 'dedupe-key'; - const controller1 = await queueRequest(key, 'dedupe-url', 2000, 1000); - const controller2 = await queueRequest(key, 'dedupe-url', 2000, 1000); + const controller1 = await markInFlight(key, 'dedupe-url', 2000, 1000); + const controller2 = await markInFlight(key, 'dedupe-url', 2000, 1000); jest.advanceTimersByTime(500); expect(controller1).toBe(controller2); }); it('should not deduplicate requests if dedupeTime is 0', async () => { const key = 'dedupe-key-0'; - const controller1 = await queueRequest(key, 'dedupe-url-0', 1000, 0); - const controller2 = await queueRequest(key, 'dedupe-url-0', 1000, 0); + const controller1 = await markInFlight(key, 'dedupe-url-0', 1000, 0); + const controller2 = await markInFlight(key, 'dedupe-url-0', 1000, 0); jest.advanceTimersByTime(1000); expect(controller1).not.toBe(controller2); }); it('should not abort the request when timeout is disabled', async () => { const key = 'timeout-disabled'; - const controller = await queueRequest( + const controller = await markInFlight( key, 'timeout-disabled-url', 0, @@ -120,28 +137,28 @@ describe('Request Queue Manager', () => { ); jest.advanceTimersByTime(1000); expect(controller.signal.aborted).toBe(false); - await removeRequestFromQueue(key, null); + await abortRequest(key, null); }); it('should handle multiple distinct requests separately', async () => { const keyA = 'distinct-a'; const keyB = 'distinct-b'; - const controllerA = await queueRequest(keyA, 'distinct-a-url', 1000, 1000); - const controllerB = await queueRequest(keyB, 'distinct-b-url', 1000, 1000); + const controllerA = await markInFlight(keyA, 'distinct-a-url', 1000, 1000); + const controllerB = await markInFlight(keyB, 'distinct-b-url', 1000, 1000); jest.advanceTimersByTime(1000); expect(controllerA).not.toBe(controllerB); }); it('should handle both timeout and cancellation correctly', async () => { const key = 'timeout-cancel'; - const controller1 = await queueRequest( + const controller1 = await markInFlight( key, 'timeout-cancel-url', 1000, 1000, true, ); - const controller2 = await queueRequest( + const controller2 = await markInFlight( key, 'timeout-cancel-url', 1000, @@ -155,14 +172,14 @@ describe('Request Queue Manager', () => { it('should handle requests with the same configuration but different options correctly', async () => { const key = 'same-config-diff-options'; - const controller1 = await queueRequest( + const controller1 = await markInFlight( key, 'same-config-diff-options-url', 2000, 1000, true, ); - const controller2 = await queueRequest( + const controller2 = await markInFlight( key, 'same-config-diff-options-url', 2000, @@ -177,19 +194,19 @@ describe('Request Queue Manager', () => { it('should handle request configuration changes correctly', async () => { const key1 = 'config-change-1'; const key2 = 'config-change-2'; - const controller1 = await queueRequest( + const controller1 = await markInFlight( key1, 'config-change-1-url', 2000, 1000, ); - const controller2 = await queueRequest( + const controller2 = await markInFlight( key2, 'config-change-2-url', 2000, 1000, ); - jest.advanceTimersByTime(1500); + jest.advanceTimersByTime(1100); expect(controller1).not.toBe(controller2); expect(controller1.signal.aborted).toBe(false); expect(controller2.signal.aborted).toBe(false); @@ -197,21 +214,21 @@ describe('Request Queue Manager', () => { it('should cancel all previous requests if they are cancellable and deduplication time is not yet passed', async () => { const key = 'cancel-prev-not-passed'; - const controller1 = await queueRequest( + const controller1 = await markInFlight( key, 'cancel-prev-not-passed-url', 2000, 1000, true, ); - const controller2 = await queueRequest( + const controller2 = await markInFlight( key, 'cancel-prev-not-passed-url', 2000, 1000, true, ); - const controller3 = await queueRequest( + const controller3 = await markInFlight( key, 'cancel-prev-not-passed-url', 2000, @@ -229,21 +246,21 @@ describe('Request Queue Manager', () => { it('should cancel all previous requests if they are cancellable and deduplication time is passed', async () => { const key = 'cancel-prev-passed'; - const controller1 = await queueRequest( + const controller1 = await markInFlight( key, 'cancel-prev-passed-url', 2000, 1000, true, ); - const controller2 = await queueRequest( + const controller2 = await markInFlight( key, 'cancel-prev-passed-url', 2000, 1000, true, ); - const controller3 = await queueRequest( + const controller3 = await markInFlight( key, 'cancel-prev-passed-url', 2000, @@ -261,21 +278,21 @@ describe('Request Queue Manager', () => { it('should cancel all requests if they are cancellable and timeout is passed', async () => { const key = 'cancel-all-timeout'; - const controller1 = await queueRequest( + const controller1 = await markInFlight( key, 'cancel-all-timeout-url', 2000, 1000, true, ); - const controller2 = await queueRequest( + const controller2 = await markInFlight( key, 'cancel-all-timeout-url', 2000, 1000, true, ); - const controller3 = await queueRequest( + const controller3 = await markInFlight( key, 'cancel-all-timeout-url', 2000, @@ -293,21 +310,21 @@ describe('Request Queue Manager', () => { it('should not cancel any request if not cancellable and deduplication time is not yet passed', async () => { const key = 'not-cancel-not-passed'; - const controller1 = await queueRequest( + const controller1 = await markInFlight( key, 'not-cancel-not-passed-url', 2000, 1000, false, ); - const controller2 = await queueRequest( + const controller2 = await markInFlight( key, 'not-cancel-not-passed-url', 2000, 1000, false, ); - const controller3 = await queueRequest( + const controller3 = await markInFlight( key, 'not-cancel-not-passed-url', 2000, @@ -325,7 +342,7 @@ describe('Request Queue Manager', () => { it('should not cancel any request if not cancellable and deduplication time is passed for each request', async () => { const key = 'not-cancel-passed'; - const controller1 = await queueRequest( + const controller1 = await markInFlight( key, 'not-cancel-passed-url', 20000, @@ -333,7 +350,7 @@ describe('Request Queue Manager', () => { false, ); jest.advanceTimersByTime(1500); - const controller2 = await queueRequest( + const controller2 = await markInFlight( key, 'not-cancel-passed-url', 20000, @@ -341,7 +358,7 @@ describe('Request Queue Manager', () => { false, ); jest.advanceTimersByTime(1500); - const controller3 = await queueRequest( + const controller3 = await markInFlight( key, 'not-cancel-passed-url', 20000, @@ -360,15 +377,17 @@ describe('Request Queue Manager', () => { it('should not cancel the previous requests if they are not cancellable', async () => { const key = 'not-cancel-prev'; - const controller1 = await queueRequest( + const controller1 = await markInFlight( key, 'not-cancel-prev-url', 2000, 1000, false, ); - jest.advanceTimersByTime(1500); - const controller2 = await queueRequest( + + jest.advanceTimersByTime(1200); + + const controller2 = await markInFlight( key, 'not-cancel-prev-url', 2000, diff --git a/test/integration/api-handler.spec.ts b/test/integration/api-handler.spec.ts new file mode 100644 index 00000000..144b8fbd --- /dev/null +++ b/test/integration/api-handler.spec.ts @@ -0,0 +1,279 @@ +import { createApiFetcher } from 'fetchff/api-handler'; +import { setDefaultConfig } from '../../src/config-handler'; +import { + clearMockResponses, + mockFetchResponse, +} from '../utils/mockFetchResponse'; + +describe('Interceptor Execution Order', () => { + const executionLog: string[] = []; + + beforeEach(() => { + executionLog.length = 0; // Clear log + clearMockResponses(); + + // Reset default config + setDefaultConfig({ + onRequest: undefined, + onResponse: undefined, + onError: undefined, + }); + }); + + afterEach(() => { + clearMockResponses(); + }); + + it('should execute interceptors in correct order: Request FIFO, Response LIFO', async () => { + // Mock the API response + mockFetchResponse('http://localhost/api/test', { + body: { message: 'success' }, + }); + + // 1. Global level (setDefaultConfig) + setDefaultConfig({ + onRequest: (config) => { + executionLog.push('Global-Request'); + return config; + }, + onResponse: (response) => { + executionLog.push('Global-Response'); + return response; + }, + }); + + // 2. Instance level (createApiFetcher config) + const api = createApiFetcher({ + apiUrl: 'http://localhost', + strategy: 'softFail', + onRequest: (config) => { + executionLog.push('Instance-Request'); + return config; + }, + onResponse: (response) => { + executionLog.push('Instance-Response'); + return response; + }, + endpoints: { + testEndpoint: { + url: '/api/test', + method: 'GET', + // 3. Endpoint level + onRequest: (config) => { + executionLog.push('Endpoint-Request'); + return config; + }, + onResponse: (response) => { + executionLog.push('Endpoint-Response'); + return response; + }, + }, + }, + }); + + // 4. Request level (when calling the endpoint) + await api.testEndpoint({ + onRequest: (config) => { + executionLog.push('Request-Request'); + return config; + }, + onResponse: (response) => { + executionLog.push('Request-Response'); + return response; + }, + }); + + // Verify execution order + expect(executionLog).toEqual([ + // Request interceptors: FIFO (First In, First Out) + 'Global-Request', + 'Instance-Request', + 'Endpoint-Request', + 'Request-Request', + + // Response interceptors: LIFO (Last In, First Out) + 'Request-Response', + 'Endpoint-Response', + 'Instance-Response', + 'Global-Response', + ]); + }); + + it('should handle array interceptors in correct order', async () => { + mockFetchResponse('http://localhost/api/array-test', { + body: { data: 'array test' }, + }); + + // Multiple interceptors at global level + setDefaultConfig({ + onRequest: [ + (config) => { + executionLog.push('Global-Request-1'); + return config; + }, + (config) => { + executionLog.push('Global-Request-2'); + return config; + }, + ], + onResponse: [ + (response) => { + executionLog.push('Global-Response-1'); + return response; + }, + (response) => { + executionLog.push('Global-Response-2'); + return response; + }, + ], + }); + + const api = createApiFetcher({ + apiUrl: 'http://localhost', + strategy: 'softFail', + endpoints: { + arrayTest: { + url: '/api/array-test', + method: 'GET', + onRequest: [ + (config) => { + executionLog.push('Endpoint-Request-1'); + return config; + }, + (config) => { + executionLog.push('Endpoint-Request-2'); + return config; + }, + ], + onResponse: [ + (response) => { + executionLog.push('Endpoint-Response-1'); + return response; + }, + (response) => { + executionLog.push('Endpoint-Response-2'); + return response; + }, + ], + }, + }, + }); + + await api.arrayTest(); + + expect(executionLog).toEqual([ + // Request: FIFO - arrays execute in order, then next level + 'Global-Request-1', + 'Global-Request-2', + 'Endpoint-Request-1', + 'Endpoint-Request-2', + + // Response: LIFO - reverse order of levels, arrays execute in order within level + 'Endpoint-Response-1', + 'Endpoint-Response-2', + 'Global-Response-1', + 'Global-Response-2', + ]); + }); + + it('should work with direct api.request() calls', async () => { + mockFetchResponse('http://localhost/api/direct', { + body: { direct: true }, + }); + + setDefaultConfig({ + onRequest: (config) => { + executionLog.push('Global-Request'); + return config; + }, + onResponse: (response) => { + executionLog.push('Global-Response'); + return response; + }, + }); + + const api = createApiFetcher({ + apiUrl: 'http://localhost', + strategy: 'softFail', + onRequest: (config) => { + executionLog.push('Instance-Request'); + return config; + }, + onResponse: (response) => { + executionLog.push('Instance-Response'); + return response; + }, + endpoints: {}, + }); + + // Direct request call with interceptors + await api.request('/api/direct', { + method: 'GET', + onRequest: (config) => { + executionLog.push('Request-Request'); + return config; + }, + onResponse: (response) => { + executionLog.push('Request-Response'); + return response; + }, + }); + + expect(executionLog).toEqual([ + // Request: FIFO + 'Global-Request', + 'Instance-Request', + 'Request-Request', + + // Response: LIFO + 'Request-Response', + 'Instance-Response', + 'Global-Response', + ]); + }); + + it('should handle missing interceptors gracefully', async () => { + mockFetchResponse('http://localhost/api/partial', { + body: { partial: true }, + }); + + // Only global request interceptor + setDefaultConfig({ + onRequest: (config) => { + executionLog.push('Global-Request'); + return config; + }, + }); + + const api = createApiFetcher({ + apiUrl: 'http://localhost', + strategy: 'softFail', + // Only instance response interceptor + onResponse: (response) => { + executionLog.push('Instance-Response'); + return response; + }, + endpoints: { + partialTest: { + url: '/api/partial', + method: 'GET', + // No interceptors at endpoint level + }, + }, + }); + + await api.partialTest({ + // Only request-level response interceptor + onResponse: (response) => { + executionLog.push('Request-Response'); + return response; + }, + }); + + expect(executionLog).toEqual([ + 'Global-Request', + 'Request-Response', + 'Instance-Response', + ]); + }); +}); diff --git a/test/interceptor-manager.spec.ts b/test/interceptor-manager.spec.ts index f741895b..52e3b0b2 100644 --- a/test/interceptor-manager.spec.ts +++ b/test/interceptor-manager.spec.ts @@ -1,49 +1,48 @@ import { ExtendedResponse } from '../src'; -import type { - FetchResponse, - RequestHandlerConfig, -} from '../src/types/request-handler'; -import { applyInterceptor } from '../src/interceptor-manager'; -import type { - RequestInterceptor, - ResponseInterceptor, -} from '../src/types/interceptor-manager'; - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type ResponseData = any; - -describe('Interceptor Functions', () => { - const interceptResponse = applyInterceptor; - const interceptRequest = applyInterceptor; - let requestInterceptors: RequestInterceptor[] = []; - let responseInterceptors: ResponseInterceptor[] = []; - - beforeEach(() => { - requestInterceptors = []; - responseInterceptors = []; +import type { RequestConfig } from '../src/types/request-handler'; +import { applyInterceptors } from '../src/interceptor-manager'; + +describe('applyInterceptors', () => { + it('should apply single interceptor to request config', async () => { + const requestInterceptor = async (config: RequestConfig) => { + return { + ...config, + headers: { ...config.headers, Authorization: 'Bearer token' }, + }; + }; + + const initialConfig: RequestConfig = { method: 'GET' }; + + await applyInterceptors(requestInterceptor, initialConfig); + + expect(initialConfig).toEqual({ + method: 'GET', + headers: { + Authorization: 'Bearer token', + }, + }); }); - it('should apply request interceptors in order', async () => { - // Define request interceptors - const requestInterceptor1 = async (config: RequestHandlerConfig) => { - config.headers = { ...config.headers, Authorization: 'Bearer token' }; - return config; + it('should apply array of interceptors in order', async () => { + const requestInterceptor1 = async (config: RequestConfig) => { + return { + ...config, + headers: { ...config.headers, Authorization: 'Bearer token' }, + }; }; - const requestInterceptor2 = async (config: RequestHandlerConfig) => { - config.headers = { ...config.headers, 'Custom-Header': 'HeaderValue' }; - return config; + const requestInterceptor2 = async (config: RequestConfig) => { + return { + ...config, + headers: { ...config.headers, 'Custom-Header': 'HeaderValue' }, + }; }; - // Register request interceptors directly - requestInterceptors.push(requestInterceptor1); - requestInterceptors.push(requestInterceptor2); + const interceptors = [requestInterceptor1, requestInterceptor2]; + const initialConfig: RequestConfig = { method: 'GET' }; - // Prepare a request configuration - const initialConfig = { method: 'GET' }; - await interceptRequest(initialConfig, requestInterceptors); + await applyInterceptors(interceptors, initialConfig); - // Validate the intercepted configuration expect(initialConfig).toEqual({ method: 'GET', headers: { @@ -53,83 +52,222 @@ describe('Interceptor Functions', () => { }); }); - it('should apply response interceptors in order', async () => { - // Define response interceptors - const responseInterceptor1 = async ( - response: FetchResponse, + it('should pass additional arguments to interceptors', async () => { + const interceptorWithArgs = async ( + data: RequestConfig, + url: string, + method: string, ) => { - const data = await response.json(); - return new Response(JSON.stringify({ ...data, modified: true }), { - status: response.status, - }) as FetchResponse; + return { + ...data, + url, + method, + headers: { ...data.headers, 'X-Custom': `${method}-${url}` }, + }; }; - const responseInterceptor2 = async ( - response: FetchResponse, - ) => { - const data = await response.json(); - return new Response(JSON.stringify({ ...data, furtherModified: true }), { - status: response.status, - }) as FetchResponse; + const initialConfig: RequestConfig = { method: 'GET' }; + + await applyInterceptors( + interceptorWithArgs, + initialConfig, + '/api/users', + 'POST', + ); + + expect(initialConfig).toEqual({ + method: 'POST', + url: '/api/users', + headers: { + 'X-Custom': 'POST-/api/users', + }, + }); + }); + + it('should handle undefined interceptors', async () => { + const initialConfig: RequestConfig = { method: 'GET' }; + const originalConfig = { ...initialConfig }; + + await applyInterceptors(undefined, initialConfig); + + expect(initialConfig).toEqual(originalConfig); + }); + + it('should handle interceptors that return undefined', async () => { + const interceptorReturningUndefined = async () => { + return undefined; }; - // Register response interceptors directly - responseInterceptors.push(responseInterceptor1); - responseInterceptors.push(responseInterceptor2); + const initialConfig: RequestConfig = { method: 'GET' }; + const originalConfig = { ...initialConfig }; - // Mock response data - const mockResponse = new Response('{"data": "test"}', { - status: 200, - }) as ExtendedResponse; + await applyInterceptors(interceptorReturningUndefined, initialConfig); - // Apply response interceptors - await interceptResponse(mockResponse, responseInterceptors); - const data = await mockResponse.json(); + expect(initialConfig).toEqual(originalConfig); + }); - // Validate the final response data - expect(data).toEqual({ - data: 'test', - modified: true, - furtherModified: true, - }); + it('should handle interceptors that return null', async () => { + const interceptorReturningNull = async () => { + return null; + }; + + const initialConfig: RequestConfig = { method: 'GET' }; + const originalConfig = { ...initialConfig }; + + await applyInterceptors(interceptorReturningNull, initialConfig); + + expect(initialConfig).toEqual(originalConfig); + }); + + it('should handle empty array of interceptors', async () => { + const initialConfig: RequestConfig = { method: 'GET' }; + const originalConfig = { ...initialConfig }; + + await applyInterceptors([], initialConfig); + + expect(initialConfig).toEqual(originalConfig); }); - it('should handle request errors', async () => { - // Mock an error in interceptRequest + it('should handle non-object return values', async () => { + const interceptorReturningString = async () => { + return 'invalid return'; + }; + + const initialConfig: RequestConfig = { method: 'GET' }; + const originalConfig = { ...initialConfig }; + + await applyInterceptors(interceptorReturningString, initialConfig); + + expect(initialConfig).toEqual(originalConfig); + }); + + it('should handle interceptors with non-object data', async () => { + const interceptor = async (data: string) => { + return data + ' modified'; + }; + + // This test verifies the function handles non-object data gracefully + const stringData = 'test' as unknown; + + await applyInterceptors(interceptor, {}, stringData); + + // Should not modify non-object data + expect(stringData).toBe('test'); + }); + + it('should handle errors thrown by interceptors', async () => { const failingInterceptor = async () => { throw new Error('Interceptor Error'); }; - // Register a failing request interceptor - requestInterceptors.push(failingInterceptor); + const initialConfig: RequestConfig = { method: 'GET' }; - // Test interceptRequest handling of errors await expect( - interceptRequest({ method: 'GET' }, requestInterceptors), + applyInterceptors(failingInterceptor, initialConfig), ).rejects.toThrow('Interceptor Error'); }); - it('should handle response errors', async () => { - // Define a response interceptor that throws an error on non-OK response + it('should handle errors in array interceptors', async () => { + const workingInterceptor = async (config: RequestConfig) => { + return { ...config, headers: { ...config.headers, Working: 'true' } }; + }; + + const failingInterceptor = async () => { + throw new Error('Second Interceptor Error'); + }; + + const interceptors = [workingInterceptor, failingInterceptor]; + const initialConfig: RequestConfig = { method: 'GET' }; + + await expect( + applyInterceptors(interceptors, initialConfig), + ).rejects.toThrow('Second Interceptor Error'); + }); + + it('should work with response objects', async () => { + interface ModifiedResponse extends ExtendedResponse { + modified?: boolean; + } + + const responseInterceptor = async (response: ModifiedResponse) => { + // Simulate modifying response by adding custom property + response.modified = true; + return response; + }; + + const mockResponse = new Response('{"data": "test"}', { + status: 200, + }) as ModifiedResponse; + + await applyInterceptors(responseInterceptor, mockResponse); + + expect(mockResponse.modified).toBe(true); + }); + + it('should handle multiple arguments with response interceptors', async () => { + interface EnhancedResponse extends ExtendedResponse { + interceptedStatus?: number; + interceptedUrl?: string; + } + const responseInterceptor = async ( - response: FetchResponse, + response: EnhancedResponse, + statusCode: number, + url: string, ) => { - if (!response.ok) { - throw new Error('Response Error'); - } + // Simulate modifying response with additional data + response.interceptedStatus = statusCode; + response.interceptedUrl = url; return response; }; - // Register response interceptor directly - responseInterceptors.push(responseInterceptor); + const mockResponse = new Response('{"data": "test"}', { + status: 200, + }) as EnhancedResponse; - // Mock response data with an error status - const errorResponse = new Response('{"data": "test"}', { - status: 500, - }) as ExtendedResponse; + await applyInterceptors( + responseInterceptor, + mockResponse, + 201, + '/api/test', + ); - await expect( - interceptResponse(errorResponse, responseInterceptors), - ).rejects.toThrow('Response Error'); + expect(mockResponse.interceptedStatus).toBe(201); + expect(mockResponse.interceptedUrl).toBe('/api/test'); + }); + + it('should handle multiple interceptors with mixed success and failure patterns', async () => { + const successInterceptor = async (config: RequestConfig) => { + return { ...config, success: true }; + }; + + const undefinedInterceptor = async () => { + return undefined; + }; + + const nullInterceptor = async () => { + return null; + }; + + const anotherSuccessInterceptor = async (config: RequestConfig) => { + return { ...config, finalStep: true }; + }; + + const interceptors = [ + successInterceptor, + undefinedInterceptor, + nullInterceptor, + anotherSuccessInterceptor, + ]; + + const initialConfig: RequestConfig = { method: 'GET' }; + + await applyInterceptors(interceptors, initialConfig); + + expect(initialConfig).toEqual({ + method: 'GET', + success: true, + finalStep: true, + }); }); }); diff --git a/test/mocks/endpoints.ts b/test/mocks/endpoints.ts index b15592b3..61306072 100644 --- a/test/mocks/endpoints.ts +++ b/test/mocks/endpoints.ts @@ -30,6 +30,10 @@ interface UserResponse { // Passing QueryParams allows for any params to be passed to the request (no strict typing) export interface EndpointsList { getUser: Endpoint; - updateUserDetails: Endpoint; - getUserByIdAndName: Endpoint; + updateUserDetails: Endpoint<{ response: UserResponse }>; + getUserByIdAndName: Endpoint<{ + response: UserResponse; + params: QueryParams; + urlPathParams: UserURLParams; + }>; } diff --git a/test/mocks/test-components.tsx b/test/mocks/test-components.tsx new file mode 100644 index 00000000..f3356fad --- /dev/null +++ b/test/mocks/test-components.tsx @@ -0,0 +1,615 @@ +import { useEffect, useState } from 'react'; +import { useFetcher } from '../../src/react/index'; +import type { RequestConfig } from '../../src/types/request-handler'; + +export interface TestData { + message?: string; + count?: number; + original?: boolean; + updated?: boolean; + id?: number; + name?: string; + shared?: string; + timestamp?: number; + deduped?: boolean; + poll?: number; + success?: boolean; + suspense?: string; + individual?: boolean; + posts?: Array<{ id: number; title: string }>; + focus?: number; + headers?: string; + created?: boolean; + userId?: number; + default?: string; + overlap?: boolean; + mutated?: boolean; + cached?: boolean; +} + +export const BasicComponent = ({ + url, + config = {}, +}: { + url: string | null; + config?: RequestConfig; +}) => { + const { + data, + error, + headers, + isLoading, + isFetching, + mutate, + refetch, + config: requestConfig, + } = useFetcher(url, config); + + return ( +
+
+ {isLoading ? 'Loading...' : 'Not Loading'} +
+
+ {isFetching ? 'Validating...' : 'Not Validating'} +
+
+ {data !== null && data !== undefined ? JSON.stringify(data) : 'No Data'} +
+
+ {headers ? JSON.stringify(headers) : 'No headers'} +
+
{error ? error.message : 'No Error'}
+
+ {requestConfig ? JSON.stringify(requestConfig) : 'No Config'} +
+ + +
+ ); +}; + +export const SuspenseComponent = ({ url }: { url: string }) => { + const { data, error, isLoading } = useFetcher(url, { + strategy: 'reject', + }); + + if (error) { + return
Error: {error.message}
; + } + + if (isLoading) { + return
Conditional Loading...
; + } + + return
{JSON.stringify(data)}
; +}; + +export const MultipleRequestsComponent = () => { + const { data: data1 } = useFetcher('/api/data-1'); + const { data: data2 } = useFetcher('/api/data-2'); + const { data: data3, config: config3 } = useFetcher('/api/data-3'); + + return ( +
+
+ {data1 ? JSON.stringify(data1) : 'No Data 1'} +
+
+ {data2 ? JSON.stringify(data2) : 'No Data 2'} +
+
+ {data3 ? JSON.stringify(data3) : 'No Data 3'} +
+
+ {config3 ? JSON.stringify(config3) : 'No Data 3 Config'} +
+
+ ); +}; + +export const ErrorHandlingComponent = ({ + shouldError, +}: { + shouldError: boolean; +}) => { + const { data, error, refetch } = useFetcher( + shouldError ? '/api/error-endpoint' : '/api/success-endpoint', + { + retry: { + retries: 2, + delay: 100, + backoff: 1.5, + }, + }, + ); + + return ( +
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+
{error ? error.message : 'No Error'}
+ +
+ ); +}; + +export const ConditionalComponent = ({ enabled }: { enabled: boolean }) => { + const { data, isLoading } = useFetcher( + enabled ? '/api/conditional' : null, + ); + + return ( +
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+
+ {isLoading ? 'Loading' : 'Not Loading'} +
+
+ ); +}; + +// Complex caching + retry + polling component +export const CacheRetryPollComponent = ({ + url, + enablePolling, + retries = 3, +}: { + url: string; + enablePolling: boolean; + retries?: number; +}) => { + const { data, error, isLoading, isFetching, refetch } = useFetcher( + url, + { + cacheTime: 10, // 10 seconds + dedupeTime: 2, // 2 seconds + refetchOnFocus: true, + pollingInterval: enablePolling ? 1000 : 0, + retry: { + retries, + delay: 100, + backoff: 2, + retryOn: [500, 502, 503], + }, + cacheKey: (config) => `complex-${config.url}-${enablePolling}`, + }, + ); + + return ( +
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+
{error?.message || 'No Error'}
+
+ {isLoading ? 'Loading' : 'Not Loading'} +
+
+ {isFetching ? 'Validating' : 'Not Validating'} +
+ +
+ ); +}; + +// Mixed strategies component +export const MixedStrategiesComponent = () => { + const { data: rejectData, error: rejectError } = useFetcher( + '/api/reject', + { + strategy: 'reject', + cacheTime: 5, + }, + ); + + const { data: softFailData } = useFetcher('/api/softfail', { + strategy: 'softFail', + defaultResponse: { message: 'fallback' }, + retry: { retries: 2, delay: 50 }, + }); + + return ( +
+
+ {rejectData ? JSON.stringify(rejectData) : 'No Reject Data'} +
+
+ {rejectError?.message || 'No Reject Error'} +
+
+ {softFailData ? JSON.stringify(softFailData) : 'No SoftFail Data'} +
+
+ ); +}; + +// Cache mutation with dependencies component +export const CacheMutationComponent = ({ userId }: { userId: number }) => { + const { data: user, mutate: mutateUser } = useFetcher( + `/api/users/${userId}`, + { + cacheTime: 30, + cacheKey: `user-${userId}`, + }, + ); + + const { data: posts, mutate: mutatePosts } = useFetcher( + `/api/users/${userId}/posts`, + { + cacheTime: 15, + cacheKey: `posts-${userId}`, + immediate: !!user, // Only fetch posts if user exists + }, + ); + + const updateUser = () => { + mutateUser({ ...user, name: 'Updated Name', mutated: true }); + // Also update posts when user changes + mutatePosts({ ...posts, cached: true }); + }; + + return ( +
+
+ {user ? JSON.stringify(user) : 'No User'} +
+
+ {posts ? JSON.stringify(posts) : 'No Posts'} +
+ +
+ ); +}; + +// Conditional with dynamic URLs component +export const ConditionalDynamicComponent = ({ + type, + id, + enabled, +}: { + type: 'user' | 'post' | null; + id?: number; + enabled: boolean; +}) => { + const url = enabled && type && id ? `/api/${type}s/${id}` : null; + + const { data, isLoading, refetch } = useFetcher(url, { + cacheTime: type === 'user' ? 60 : 30, // Different cache times + dedupeTime: 5, + retry: { + retries: type === 'user' ? 3 : 1, // Different retry strategies + delay: 200, + }, + params: type === 'post' ? { include: 'comments' } : undefined, + }); + + return ( +
+
{url || 'No URL'}
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+
+ {isLoading ? 'Loading' : 'Not Loading'} +
+ +
+ ); +}; + +// Overlapping requests with different configs +export const OverlappingRequestsComponent = ({ + phase, +}: { + phase: 1 | 2 | 3; +}) => { + // Same URL but different configs based on phase + const config1: RequestConfig = { + cacheTime: phase === 1 ? 10 : 0, + dedupeTime: 1, + method: 'GET', + }; + + const config2: RequestConfig = { + cacheTime: 20, + method: phase === 2 ? 'POST' : 'GET', + body: phase === 2 ? { data: 'test' } : undefined, + immediate: phase !== 2, + }; + + const { data: data1, isLoading: loading1 } = useFetcher( + '/api/overlap', + config1, + ); + const { + data: data2, + isLoading: loading2, + refetch, + } = useFetcher('/api/overlap', config2); + + return ( +
+
+ {data1 ? JSON.stringify(data1) : 'No Data1'} +
+
+ {data2 ? JSON.stringify(data2) : 'No Data2'} +
+
+ {loading1 || loading2 ? 'Loading' : 'Not Loading'} +
+
{phase}
+ +
+ ); +}; + +// Error boundaries with different error types +export const ErrorTypesComponent = ({ + errorType, +}: { + errorType: 'network' | '500' | '404' | 'timeout' | 'success'; +}) => { + const getUrl = () => { + switch (errorType) { + case 'network': + return '/api/network-error'; + case '500': + return '/api/server-error'; + case '404': + return '/api/not-found'; + case 'timeout': + return '/api/slow-endpoint'; + case 'success': + return '/api/success'; + } + }; + + const { data, error, isLoading } = useFetcher(getUrl(), { + timeout: errorType === 'timeout' ? 100 : 5000, + retry: { + retries: errorType === '500' ? 3 : 1, + delay: 50, + retryOn: errorType === '404' ? [] : [500, 502, 503], + }, + strategy: errorType === '404' ? 'softFail' : 'reject', + defaultResponse: { error: true }, + }); + + return ( +
+
{errorType}
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+
{error?.message || 'No Error'}
+
+ {isLoading ? 'Loading' : 'Not Loading'} +
+
+ ); +}; + +export const PaginationComponent = () => { + const [page, setPage] = useState(1); + const limit = 10; + + const { data, isLoading, error } = useFetcher<{ + data: Array<{ id: number; title: string }>; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + hasNext: boolean; + hasPrev: boolean; + }; + }>('/api/posts', { + params: { page, limit }, + cacheTime: 30, + cacheKey: `posts-page-${page}`, + }); + + return ( +
+
+ {isLoading ? 'Loading' : 'Not Loading'} +
+
+ {error ? error.message : 'No Error'} +
+
+ {data?.data ? JSON.stringify(data.data) : 'No Data'} +
+
+ {data?.pagination + ? `Page ${data.pagination.page} of ${data.pagination.totalPages}` + : 'No Pagination Info'} +
+ + +
{page}
+
+ ); +}; + +export const InfiniteScrollComponent = () => { + const [allItems, setAllItems] = useState< + Array<{ id: number; content: string }> + >([]); + const [offset, setOffset] = useState(0); + const [hasMore, setHasMore] = useState(true); + + const { data, isLoading } = useFetcher<{ + items: Array<{ id: number; content: string }>; + hasMore: boolean; + nextOffset: number | null; + }>('/api/feed', { + params: { offset, limit: 5 }, + cacheTime: 0, // Don't cache for infinite scroll + immediate: hasMore, // Only fetch if there's more data + }); + + useEffect(() => { + if (data?.items) { + setAllItems((prev) => [...prev, ...data.items]); + setHasMore(data.hasMore); + if (data.nextOffset !== null) { + // Don't auto-advance here, wait for user action + } + } + }, [data]); + + const loadMore = () => { + if (data && data.nextOffset !== null && hasMore) { + setOffset(data.nextOffset); + } + }; + + return ( +
+
+ {allItems.map((item) => ( +
+ {item.content} +
+ ))} +
+
+ {isLoading ? 'Loading More' : 'Not Loading'} +
+
{allItems.length}
+ +
{hasMore ? 'Has More' : 'No More'}
+
+ ); +}; + +export const SearchPaginationComponent = () => { + const [search, setSearch] = useState('john'); + const [status, setStatus] = useState('active'); + const [page, setPage] = useState(1); + + const { data, isLoading } = useFetcher<{ + users: Array<{ id: number; name: string; status: string }>; + pagination: { + page: number; + limit: number; + total: number; + totalPages: number; + }; + }>('/api/users', { + params: { search, status, page, limit: 3 }, + cacheTime: 60, + cacheKey: `users-${search}-${status}-${page}`, + dedupeTime: 1000, // Dedupe rapid searches + }); + + // Reset page when search changes + useEffect(() => { + setPage(1); + }, [search, status]); + + return ( +
+ setSearch(e.target.value)} + data-testid="search-input" + placeholder="Search users..." + /> + + +
+ {isLoading ? 'Searching' : 'Not Searching'} +
+ +
+ {data?.users?.map((user) => ( +
+ {user.name} - {user.status} +
+ )) || 'No Results'} +
+ +
+ {data?.pagination ? `Total: ${data.pagination.total}` : 'No Total'} +
+ +
+ {data?.pagination ? `Page: ${data.pagination.page}` : 'No Page'} +
+
+ ); +}; + +export const ErrorPaginationComponent = ({ attemptCount = 0 }) => { + const [page, setPage] = useState(1); + + const { data, error, isLoading } = useFetcher('/api/posts-error', { + params: { page }, + retry: { retries: 3, delay: 100, backoff: 1.5 }, + cacheTime: 0, // Don't cache error responses + }); + + return ( +
+
+ {data?.data ? JSON.stringify(data.data) : 'No Data'} +
+
+ {error ? `Error: ${error.status}` : 'No Error'} +
+
+ {isLoading ? 'Loading' : 'Not Loading'} +
+ +
{attemptCount}
+
+ ); +}; diff --git a/test/pubsub-manager.spec.ts b/test/pubsub-manager.spec.ts new file mode 100644 index 00000000..3ffed063 --- /dev/null +++ b/test/pubsub-manager.spec.ts @@ -0,0 +1,339 @@ +import { + addListener, + removeListener, + notifySubscribers, + subscribe, +} from '../src/pubsub-manager'; + +describe('PubSub Manager', () => { + let mockListener1: jest.Mock; + let mockListener2: jest.Mock; + let mockListener3: jest.Mock; + const testKey = 'test-key'; + const testKey2 = 'test-key-2'; + const testData = { data: 'test-data', id: 123 }; + + beforeEach(() => { + mockListener1 = jest.fn(); + mockListener2 = jest.fn(); + mockListener3 = jest.fn(); + }); + + afterEach(() => { + jest.clearAllMocks(); + // Clean up any listeners by calling removeListener + removeListener(testKey, mockListener1); + removeListener(testKey, mockListener2); + removeListener(testKey, mockListener3); + removeListener(testKey2, mockListener1); + removeListener(testKey2, mockListener2); + }); + + describe('addListener', () => { + it('should add a listener for a key', () => { + const result = addListener(testKey, mockListener1); + + expect(result).toBeUndefined(); + + // Verify the listener was added by testing notification + notifySubscribers(testKey, testData); + expect(mockListener1).toHaveBeenCalledWith(testData); + }); + + it('should add multiple listeners for the same key', () => { + addListener(testKey, mockListener1); + const result = addListener(testKey, mockListener2); + + expect(result).toBeUndefined(); + + // Verify both listeners were added + notifySubscribers(testKey, testData); + expect(mockListener1).toHaveBeenCalledWith(testData); + expect(mockListener2).toHaveBeenCalledWith(testData); + }); + + it('should add the same listener only once', () => { + addListener(testKey, mockListener1); + addListener(testKey, mockListener1); // Add same listener again + + // Verify listener is only called once even though added twice + notifySubscribers(testKey, testData); + expect(mockListener1).toHaveBeenCalledTimes(1); + }); + + it('should handle different keys independently', () => { + addListener(testKey, mockListener1); + addListener(testKey2, mockListener2); + + // Notify first key + notifySubscribers(testKey, testData); + expect(mockListener1).toHaveBeenCalledWith(testData); + expect(mockListener2).not.toHaveBeenCalled(); + + // Notify second key + notifySubscribers(testKey2, testData); + expect(mockListener2).toHaveBeenCalledWith(testData); + expect(mockListener1).toHaveBeenCalledTimes(1); // Still only called once + }); + + it('should handle empty string as key', () => { + const result = addListener('', mockListener1); + + expect(result).toBeUndefined(); + + // Verify it works with empty string key + notifySubscribers('', testData); + expect(mockListener1).toHaveBeenCalledWith(testData); + }); + }); + + describe('removeListener', () => { + it('should remove a listener from a key', () => { + addListener(testKey, mockListener1); + addListener(testKey, mockListener2); + + // Verify both listeners work initially + notifySubscribers(testKey, testData); + expect(mockListener1).toHaveBeenCalledTimes(1); + expect(mockListener2).toHaveBeenCalledTimes(1); + + // Remove one listener + removeListener(testKey, mockListener1); + + // Verify only the remaining listener is called + notifySubscribers(testKey, testData); + expect(mockListener1).toHaveBeenCalledTimes(1); // No change + expect(mockListener2).toHaveBeenCalledTimes(2); // Called again + }); + + it('should handle removing non-existent listener gracefully', () => { + addListener(testKey, mockListener1); + + expect(() => removeListener(testKey, mockListener2)).not.toThrow(); + + // Verify original listener still works + notifySubscribers(testKey, testData); + expect(mockListener1).toHaveBeenCalledWith(testData); + }); + + it('should handle removing from non-existent key gracefully', () => { + expect(() => + removeListener('non-existent-key', mockListener1), + ).not.toThrow(); + }); + + it('should handle empty string key', () => { + addListener('', mockListener1); + + expect(() => removeListener('', mockListener1)).not.toThrow(); + + // Verify listener was removed + notifySubscribers('', testData); + expect(mockListener1).not.toHaveBeenCalled(); + }); + }); + + describe('notifySubscribers', () => { + it('should notify all listeners for a key', () => { + addListener(testKey, mockListener1); + addListener(testKey, mockListener2); + + notifySubscribers(testKey, testData); + + expect(mockListener1).toHaveBeenCalledWith(testData); + expect(mockListener2).toHaveBeenCalledWith(testData); + expect(mockListener1).toHaveBeenCalledTimes(1); + expect(mockListener2).toHaveBeenCalledTimes(1); + }); + + it('should not notify listeners for different keys', () => { + addListener(testKey, mockListener1); + addListener(testKey2, mockListener2); + + notifySubscribers(testKey, testData); + + expect(mockListener1).toHaveBeenCalledWith(testData); + expect(mockListener2).not.toHaveBeenCalled(); + }); + + it('should handle notifying non-existent key gracefully', () => { + expect(() => + notifySubscribers('non-existent-key', testData), + ).not.toThrow(); + }); + + it('should notify with different data types', () => { + addListener(testKey, mockListener1); + + const stringData = 'string-data'; + const numberData = 42; + const objectData = { complex: { nested: 'object' } }; + const arrayData = [1, 2, 3]; + + notifySubscribers(testKey, stringData); + notifySubscribers(testKey, numberData); + notifySubscribers(testKey, objectData); + notifySubscribers(testKey, arrayData); + + expect(mockListener1).toHaveBeenNthCalledWith(1, stringData); + expect(mockListener1).toHaveBeenNthCalledWith(2, numberData); + expect(mockListener1).toHaveBeenNthCalledWith(3, objectData); + expect(mockListener1).toHaveBeenNthCalledWith(4, arrayData); + expect(mockListener1).toHaveBeenCalledTimes(4); + }); + + it('should handle listener throwing error gracefully', () => { + const errorTestKey = 'error-test-key'; // Use a dedicated key for this test + const errorListener = jest.fn().mockImplementation(() => { + throw new Error('Listener error'); + }); + addListener(errorTestKey, errorListener); + addListener(errorTestKey, mockListener1); + + // This should not prevent other listeners from being called + expect(() => notifySubscribers(errorTestKey, testData)).toThrow( + 'Listener error', + ); + + expect(errorListener).toHaveBeenCalledWith(testData); + // Note: Due to forEach implementation, if first listener throws, + // subsequent listeners might not be called + + // Clean up the error listener + removeListener(errorTestKey, errorListener); + removeListener(errorTestKey, mockListener1); + }); + }); + + describe('subscribe', () => { + it('should add a listener and return unsubscribe function', () => { + const unsubscribe = subscribe(testKey, mockListener1); + + expect(typeof unsubscribe).toBe('function'); + + notifySubscribers(testKey, testData); + expect(mockListener1).toHaveBeenCalledWith(testData); + }); + + it('should allow unsubscribing', () => { + const unsubscribe = subscribe(testKey, mockListener1); + + notifySubscribers(testKey, testData); + expect(mockListener1).toHaveBeenCalledTimes(1); + + unsubscribe(); + + notifySubscribers(testKey, testData); + expect(mockListener1).toHaveBeenCalledTimes(1); // Should not increase + }); + + it('should remove key from listeners map when all subscribers unsubscribe', () => { + const unsubscribe1 = subscribe(testKey, mockListener1); + const unsubscribe2 = subscribe(testKey, mockListener2); + + // Both listeners should receive notifications + notifySubscribers(testKey, testData); + expect(mockListener1).toHaveBeenCalledTimes(1); + expect(mockListener2).toHaveBeenCalledTimes(1); + + // Unsubscribe first listener + unsubscribe1(); + notifySubscribers(testKey, testData); + expect(mockListener1).toHaveBeenCalledTimes(1); // No change + expect(mockListener2).toHaveBeenCalledTimes(2); // Still receiving + + // Unsubscribe last listener - this should clean up the key + unsubscribe2(); + notifySubscribers(testKey, testData); + expect(mockListener1).toHaveBeenCalledTimes(1); + expect(mockListener2).toHaveBeenCalledTimes(2); // No change + }); + + it('should handle multiple subscriptions of the same function', () => { + const unsubscribe1 = subscribe(testKey, mockListener1); + const unsubscribe2 = subscribe(testKey, mockListener1); + + notifySubscribers(testKey, testData); + // Should only be called once since it's the same function reference + expect(mockListener1).toHaveBeenCalledTimes(1); + + unsubscribe1(); + notifySubscribers(testKey, testData); + // Should not be called since the function was removed + expect(mockListener1).toHaveBeenCalledTimes(1); + + // Second unsubscribe should be safe to call + expect(() => unsubscribe2()).not.toThrow(); + }); + + it('should handle unsubscribing multiple times safely', () => { + const unsubscribe = subscribe(testKey, mockListener1); + + unsubscribe(); + expect(() => unsubscribe()).not.toThrow(); + expect(() => unsubscribe()).not.toThrow(); + }); + + it('should handle null key gracefully', () => { + const unsubscribe = subscribe(null, mockListener1); + + expect(typeof unsubscribe).toBe('function'); + + // Should not throw when called + expect(() => unsubscribe()).not.toThrow(); + + // Listener should not be called for any notifications + notifySubscribers('any-key', testData); + expect(mockListener1).not.toHaveBeenCalled(); + }); + }); + + describe('integration scenarios', () => { + it('should handle complex subscriber management', () => { + // Multiple subscribers for multiple keys + const unsubscribe1 = subscribe(testKey, mockListener1); + const unsubscribe2 = subscribe(testKey, mockListener2); + const unsubscribe3 = subscribe(testKey2, mockListener3); + + // Notify first key + notifySubscribers(testKey, testData); + expect(mockListener1).toHaveBeenCalledWith(testData); + expect(mockListener2).toHaveBeenCalledWith(testData); + expect(mockListener3).not.toHaveBeenCalled(); + + // Notify second key + const testData2 = { different: 'data' }; + notifySubscribers(testKey2, testData2); + expect(mockListener3).toHaveBeenCalledWith(testData2); + + // Unsubscribe and verify isolation + unsubscribe1(); + notifySubscribers(testKey, testData); + expect(mockListener1).toHaveBeenCalledTimes(1); // No change + expect(mockListener2).toHaveBeenCalledTimes(2); // Still active + + // Clean up + unsubscribe2(); + unsubscribe3(); + }); + + it('should handle edge case with empty data', () => { + const unsubscribe = subscribe(testKey, mockListener1); + + notifySubscribers(testKey, null); + notifySubscribers(testKey, undefined); + notifySubscribers(testKey, ''); + notifySubscribers(testKey, 0); + notifySubscribers(testKey, false); + + expect(mockListener1).toHaveBeenNthCalledWith(1, null); + expect(mockListener1).toHaveBeenNthCalledWith(2, undefined); + expect(mockListener1).toHaveBeenNthCalledWith(3, ''); + expect(mockListener1).toHaveBeenNthCalledWith(4, 0); + expect(mockListener1).toHaveBeenNthCalledWith(5, false); + expect(mockListener1).toHaveBeenCalledTimes(5); + + unsubscribe(); + }); + }); +}); diff --git a/test/react/cache-ref.spec.ts b/test/react/cache-ref.spec.ts new file mode 100644 index 00000000..0e49022d --- /dev/null +++ b/test/react/cache-ref.spec.ts @@ -0,0 +1,466 @@ +import { + incrementRef, + decrementRef, + getRefCount, + clearRefCache, + INFINITE_CACHE_TIME, +} from '../../src/react/cache-ref'; +import { deleteCache } from '../../src/cache-manager'; + +// Mock the deleteCache function +jest.mock('../../src/cache-manager', () => ({ + deleteCache: jest.fn(), +})); + +const mockDeleteCache = deleteCache as jest.MockedFunction; + +describe('Cache Reference Management', () => { + const testKey = 'test-cache-key'; + const testKey2 = 'test-cache-key-2'; + const dedupeTime = 100; + + beforeEach(() => { + jest.useFakeTimers(); + clearRefCache(); + mockDeleteCache.mockClear(); + }); + + afterEach(() => { + jest.useRealTimers(); + clearRefCache(); + }); + + describe('incrementRef', () => { + it('should initialize ref count to 1 for new key', () => { + incrementRef(testKey); + expect(getRefCount(testKey)).toBe(1); + }); + + it('should increment existing ref count', () => { + incrementRef(testKey); + incrementRef(testKey); + incrementRef(testKey); + expect(getRefCount(testKey)).toBe(3); + }); + + it('should handle null key gracefully', () => { + incrementRef(null); + expect(getRefCount(null)).toBe(0); + }); + + it('should handle multiple different keys independently', () => { + incrementRef(testKey); + incrementRef(testKey2); + incrementRef(testKey); + + expect(getRefCount(testKey)).toBe(2); + expect(getRefCount(testKey2)).toBe(1); + }); + }); + + describe('decrementRef', () => { + describe('Basic functionality', () => { + it('should decrement ref count', () => { + incrementRef(testKey); + incrementRef(testKey); + + decrementRef(testKey); + expect(getRefCount(testKey)).toBe(1); + }); + + it('should handle null key gracefully', () => { + expect(() => decrementRef(null)).not.toThrow(); + }); + + it('should handle non-existent key gracefully', () => { + expect(() => decrementRef('non-existent')).not.toThrow(); + expect(getRefCount('non-existent')).toBe(0); + }); + + it('should not go below zero', () => { + incrementRef(testKey); + decrementRef(testKey); + decrementRef(testKey); // Try to go negative + + expect(getRefCount(testKey)).toBe(0); + }); + }); + + describe('Infinite cache behavior', () => { + it('should delete cache when ref count drops to 0 for infinite cache', () => { + incrementRef(testKey); + decrementRef(testKey, INFINITE_CACHE_TIME, dedupeTime); + + expect(getRefCount(testKey)).toBe(0); + expect(mockDeleteCache).not.toHaveBeenCalled(); // Not called yet + + // Advance timers past dedupeTime + jest.advanceTimersByTime(dedupeTime); + + expect(mockDeleteCache).toHaveBeenCalledWith(testKey, true); + expect(mockDeleteCache).toHaveBeenCalledTimes(1); + }); + + it('should not delete cache for non-infinite cache times', () => { + incrementRef(testKey); + decrementRef(testKey, 300, dedupeTime); // 5 minutes cache + + expect(getRefCount(testKey)).toBe(0); + + jest.advanceTimersByTime(dedupeTime); + + expect(mockDeleteCache).not.toHaveBeenCalled(); + }); + + it('should not delete cache when cacheTime is 0', () => { + incrementRef(testKey); + decrementRef(testKey, 0, dedupeTime); + + jest.advanceTimersByTime(dedupeTime); + + expect(mockDeleteCache).not.toHaveBeenCalled(); + }); + + it('should not delete cache when cacheTime is undefined', () => { + incrementRef(testKey); + decrementRef(testKey, undefined, dedupeTime); + + jest.advanceTimersByTime(dedupeTime); + + expect(mockDeleteCache).not.toHaveBeenCalled(); + }); + }); + + describe('Race condition prevention', () => { + it('should prevent deletion if ref count increases during timeout', () => { + incrementRef(testKey); + decrementRef(testKey, INFINITE_CACHE_TIME, dedupeTime); + + expect(getRefCount(testKey)).toBe(0); + + // Simulate new component mounting during timeout + incrementRef(testKey); + expect(getRefCount(testKey)).toBe(1); + + // Advance timer + jest.advanceTimersByTime(dedupeTime); + + // Cache should NOT be deleted because ref count is now > 0 + expect(mockDeleteCache).not.toHaveBeenCalled(); + }); + + it('should handle multiple increments during timeout', () => { + incrementRef(testKey); + decrementRef(testKey, INFINITE_CACHE_TIME, dedupeTime); + + // Multiple components mount during timeout + incrementRef(testKey); + incrementRef(testKey); + incrementRef(testKey); + + expect(getRefCount(testKey)).toBe(3); + + jest.advanceTimersByTime(dedupeTime); + + expect(mockDeleteCache).not.toHaveBeenCalled(); + }); + + it('should still delete if no new refs are added during timeout', () => { + incrementRef(testKey); + decrementRef(testKey, INFINITE_CACHE_TIME, dedupeTime); + + // No new increments happen + jest.advanceTimersByTime(dedupeTime); + + expect(mockDeleteCache).toHaveBeenCalledWith(testKey, true); + }); + }); + + describe('Complex scenarios', () => { + it('should handle multiple components with same cache key', () => { + // Simulate 3 components using same cache key + incrementRef(testKey); // Component A + incrementRef(testKey); // Component B + incrementRef(testKey); // Component C + + expect(getRefCount(testKey)).toBe(3); + + // Component A unmounts + decrementRef(testKey, INFINITE_CACHE_TIME, dedupeTime); + expect(getRefCount(testKey)).toBe(2); + + jest.advanceTimersByTime(dedupeTime); + expect(mockDeleteCache).not.toHaveBeenCalled(); + + // Component B unmounts + decrementRef(testKey, INFINITE_CACHE_TIME, dedupeTime); + expect(getRefCount(testKey)).toBe(1); + + jest.advanceTimersByTime(dedupeTime); + expect(mockDeleteCache).not.toHaveBeenCalled(); + + // Component C unmounts (last one) + decrementRef(testKey, INFINITE_CACHE_TIME, dedupeTime); + expect(getRefCount(testKey)).toBe(0); + + jest.advanceTimersByTime(dedupeTime); + expect(mockDeleteCache).toHaveBeenCalledWith(testKey, true); + expect(mockDeleteCache).toHaveBeenCalledTimes(1); + }); + + it('should handle rapid mount/unmount cycles', () => { + // Rapid mount/unmount/mount cycle + incrementRef(testKey); + decrementRef(testKey, INFINITE_CACHE_TIME, dedupeTime); + // Ref count is 0, deletion scheduled + + // Before timeout, new component mounts + incrementRef(testKey); + expect(getRefCount(testKey)).toBe(1); + + // Another unmount + decrementRef(testKey, INFINITE_CACHE_TIME, dedupeTime); + expect(getRefCount(testKey)).toBe(0); + + // Yet another mount before any timeout + incrementRef(testKey); + expect(getRefCount(testKey)).toBe(1); + + // Advance all timers + jest.advanceTimersByTime(dedupeTime * 3); + + // Cache should not be deleted because final state has refs + expect(mockDeleteCache).not.toHaveBeenCalled(); + }); + + it('should handle concurrent operations on different keys', () => { + // Setup multiple keys with refs + incrementRef(testKey); + incrementRef(testKey2); + + // Decrement both to trigger deletion timers + decrementRef(testKey, INFINITE_CACHE_TIME, dedupeTime); + decrementRef(testKey2, INFINITE_CACHE_TIME, dedupeTime); + + // Add ref back to only one key + incrementRef(testKey); + + jest.advanceTimersByTime(dedupeTime); + + // Only testKey2 should be deleted + expect(mockDeleteCache).toHaveBeenCalledWith(testKey2, true); + expect(mockDeleteCache).not.toHaveBeenCalledWith(testKey); + expect(mockDeleteCache).toHaveBeenCalledTimes(1); + }); + + it('should handle race condition with timeout correctly', () => { + incrementRef(testKey); + + // Decrement to 0 - this schedules deletion + decrementRef(testKey, INFINITE_CACHE_TIME, dedupeTime); + expect(getRefCount(testKey)).toBe(0); + + // Before timeout fires, add ref back + incrementRef(testKey); + expect(getRefCount(testKey)).toBe(1); + + // When timeout fires, it should see ref count > 0 and not delete + jest.advanceTimersByTime(dedupeTime); + expect(mockDeleteCache).not.toHaveBeenCalled(); + + // Now decrement again and let it delete + decrementRef(testKey, INFINITE_CACHE_TIME, dedupeTime); + expect(getRefCount(testKey)).toBe(0); + + jest.advanceTimersByTime(dedupeTime); + expect(mockDeleteCache).toHaveBeenCalledWith(testKey, true); + expect(mockDeleteCache).toHaveBeenCalledTimes(1); + }); + + it('should handle default dedupeTime when not provided', () => { + incrementRef(testKey); + decrementRef(testKey, INFINITE_CACHE_TIME); // No dedupeTime provided + + // Should still work with undefined dedupeTime + jest.advanceTimersByTime(0); + + // The implementation should handle undefined gracefully + expect(() => jest.advanceTimersToNextTimer()).not.toThrow(); + }); + }); + + describe('Edge cases', () => { + it('should handle system clock changes during timeout', () => { + incrementRef(testKey); + decrementRef(testKey, INFINITE_CACHE_TIME, dedupeTime); + + // Simulate system clock jumping (timer still fires) + jest.advanceTimersByTime(dedupeTime); + + expect(mockDeleteCache).toHaveBeenCalledWith(testKey, true); + }); + + it('should handle very large ref counts', () => { + // Add many refs + for (let i = 0; i < 10000; i++) { + incrementRef(testKey); + } + + expect(getRefCount(testKey)).toBe(10000); + + // Remove all but one + for (let i = 0; i < 9999; i++) { + decrementRef(testKey, INFINITE_CACHE_TIME, dedupeTime); + } + + expect(getRefCount(testKey)).toBe(1); + expect(mockDeleteCache).not.toHaveBeenCalled(); + + // Remove last one + decrementRef(testKey, INFINITE_CACHE_TIME, dedupeTime); + jest.advanceTimersByTime(dedupeTime); + + expect(mockDeleteCache).toHaveBeenCalledWith(testKey, true); + }); + + it('should handle empty string as key gracefully', () => { + const emptyKey = ''; + + // Test behavior with empty string + incrementRef(emptyKey); + const refCount = getRefCount(emptyKey); + + // The implementation might treat empty string as invalid + // If so, it should behave like null key + expect(refCount).toBeGreaterThanOrEqual(0); + + // If ref count is 0, empty string is treated as invalid + if (refCount === 0) { + // Should handle decrementRef gracefully too + expect(() => + decrementRef(emptyKey, INFINITE_CACHE_TIME, dedupeTime), + ).not.toThrow(); + } else { + // Empty string is supported + decrementRef(emptyKey, INFINITE_CACHE_TIME, dedupeTime); + expect(getRefCount(emptyKey)).toBe(0); + + jest.advanceTimersByTime(dedupeTime); + expect(mockDeleteCache).toHaveBeenCalledWith(emptyKey); + } + }); + + it('should handle special characters in key', () => { + const specialKey = 'key/with:special@characters#test'; + incrementRef(specialKey); + decrementRef(specialKey, INFINITE_CACHE_TIME, dedupeTime); + + jest.advanceTimersByTime(dedupeTime); + + expect(mockDeleteCache).toHaveBeenCalledWith(specialKey, true); + }); + }); + }); + + describe('getRefCount', () => { + it('should return 0 for non-existent key', () => { + expect(getRefCount('non-existent')).toBe(0); + }); + + it('should return 0 for null key', () => { + expect(getRefCount(null)).toBe(0); + }); + + it('should return correct count for existing key', () => { + incrementRef(testKey); + incrementRef(testKey); + expect(getRefCount(testKey)).toBe(2); + }); + + it('should return 0 after ref is deleted', () => { + incrementRef(testKey); + decrementRef(testKey, INFINITE_CACHE_TIME, dedupeTime); + expect(getRefCount(testKey)).toBe(0); + }); + }); + + describe('clearRefCache', () => { + it('should clear all ref counts', () => { + incrementRef(testKey); + incrementRef(testKey2); + incrementRef('another-key'); + + expect(getRefCount(testKey)).toBe(1); + expect(getRefCount(testKey2)).toBe(1); + expect(getRefCount('another-key')).toBe(1); + + clearRefCache(); + + expect(getRefCount(testKey)).toBe(0); + expect(getRefCount(testKey2)).toBe(0); + expect(getRefCount('another-key')).toBe(0); + }); + + it('should not affect pending deletion timers', () => { + incrementRef(testKey); + decrementRef(testKey, INFINITE_CACHE_TIME, dedupeTime); + + clearRefCache(); + + // Timer should still fire and attempt deletion + jest.advanceTimersByTime(dedupeTime); + expect(mockDeleteCache).toHaveBeenCalledWith(testKey, true); + }); + }); + + describe('Integration scenarios', () => { + it('should simulate React component lifecycle accurately', () => { + // Component mounts + incrementRef(testKey); + expect(getRefCount(testKey)).toBe(1); + + // Component updates (no ref change) + expect(getRefCount(testKey)).toBe(1); + + // Second component with same key mounts + incrementRef(testKey); + expect(getRefCount(testKey)).toBe(2); + + // First component unmounts + decrementRef(testKey, INFINITE_CACHE_TIME, dedupeTime); + expect(getRefCount(testKey)).toBe(1); + + // Cache should not be deleted + jest.advanceTimersByTime(dedupeTime); + expect(mockDeleteCache).not.toHaveBeenCalled(); + + // Second component unmounts + decrementRef(testKey, INFINITE_CACHE_TIME, dedupeTime); + expect(getRefCount(testKey)).toBe(0); + + // Cache should be deleted after timeout + jest.advanceTimersByTime(dedupeTime); + expect(mockDeleteCache).toHaveBeenCalledWith(testKey, true); + }); + + it('should handle React Strict Mode double mounting', () => { + // Strict mode mounts component twice + incrementRef(testKey); + incrementRef(testKey); + expect(getRefCount(testKey)).toBe(2); + + // Then unmounts once (cleanup of first mount) + decrementRef(testKey, INFINITE_CACHE_TIME, dedupeTime); + expect(getRefCount(testKey)).toBe(1); + + // Should not delete cache + jest.advanceTimersByTime(dedupeTime); + expect(mockDeleteCache).not.toHaveBeenCalled(); + + // Final unmount + decrementRef(testKey, INFINITE_CACHE_TIME, dedupeTime); + jest.advanceTimersByTime(dedupeTime); + expect(mockDeleteCache).toHaveBeenCalledWith(testKey, true); + }); + }); +}); diff --git a/test/react/index.spec.ts b/test/react/index.spec.ts new file mode 100644 index 00000000..5d3aa3fd --- /dev/null +++ b/test/react/index.spec.ts @@ -0,0 +1,698 @@ +/** + * @jest-environment jsdom + */ +import { renderHook, act, waitFor } from '@testing-library/react'; +import { useFetcher } from '../../src/react/index'; +import { pruneCache } from '../../src/cache-manager'; +import { removeRevalidators } from '../../src/revalidator-manager'; +import { + mockFetchResponse, + clearMockResponses, +} from '../utils/mockFetchResponse'; + +describe('useFetcher', () => { + const testUrl = 'https://api.example.com/data'; + const testData = { id: 1, name: 'Test' }; + + beforeEach(() => { + jest.useFakeTimers(); + mockFetchResponse(testUrl, { body: testData }); + }); + + afterEach(() => { + pruneCache(); + removeRevalidators('focus'); // Clean up revalidators after each test + removeRevalidators('online'); // Clean up revalidators after each test + clearMockResponses(); + jest.runAllTimers(); // Advance timers to ensure all promises resolve + jest.useRealTimers(); + }); + + describe('Basic Functionality', () => { + it('should initialize with loading state when no cached data exists', async () => { + const { result } = renderHook(() => useFetcher(testUrl)); + + expect(result.current.data).toBeNull(); + expect(result.current.error).toBeNull(); + expect(result.current.isLoading).toBe(true); + expect(typeof result.current.refetch).toBe('function'); + expect(typeof result.current.mutate).toBe('function'); + + await act(async () => { + jest.runAllTimers(); + }); + }); + + it('should fetch data when component mounts', async () => { + const { result } = renderHook(() => useFetcher(testUrl)); + + // Initially loading + expect(result.current.isLoading).toBe(true); + expect(result.current.data).toBeNull(); + + // Wait for fetch to complete + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toEqual(testData); + expect(result.current.error).toBeNull(); + }); + }); + + it('should return cached data on subsequent renders', async () => { + const { result: result1 } = renderHook(() => useFetcher(testUrl)); + + await waitFor(() => { + expect(result1.current.data).toEqual(testData); + }); + + // Second render - should use cache + const { result: result2 } = renderHook(() => useFetcher(testUrl)); + + // Should immediately have data from cache + expect(result2.current.data).toEqual(testData); + expect(result2.current.isLoading).toBe(false); + }); + }); + + describe('Error Handling', () => { + it('should handle error state', async () => { + const errorUrl = 'https://api.example.com/error'; + mockFetchResponse(errorUrl, { status: 500 }, true); + + const { result } = renderHook(() => useFetcher(errorUrl)); + + await waitFor(() => { + expect(result.current.data).toBeNull(); + expect(result.current.error).toBeTruthy(); + expect(result.current.isLoading).toBe(false); + }); + }); + }); + + describe('refetch & mutate Functions', () => { + it('should refetch data when called', async () => { + const updatedData = { id: 2, name: 'Updated' }; + + // Initial response + mockFetchResponse(testUrl, { body: testData }); + + const { result, unmount } = renderHook(() => useFetcher(testUrl)); + + // Wait for initial data + await waitFor(() => { + expect(result.current.data).toEqual(testData); + }); + + // Update the mock for refetch + mockFetchResponse(testUrl, { body: updatedData }); + + // Call refetch + await act(async () => { + await result.current.refetch(); + }); + + // Should have updated data + expect(result.current.data).toEqual(updatedData); + + unmount(); + }); + + it('should update data immediately when mutate is called', async () => { + mockFetchResponse(testUrl, { body: testData }); + + const { result, unmount } = renderHook(() => useFetcher(testUrl)); + + // Wait for initial data + await waitFor(() => { + expect(result.current.data).toEqual(testData); + }); + + const mutatedData = { id: 2, name: 'Mutated' }; + + // Call mutate + act(() => { + result.current.mutate(mutatedData); + }); + + // Should immediately show mutated data + await waitFor(() => { + expect(result.current.data).toEqual(mutatedData); + unmount(); + }); + }); + + it('should support revalidate option', async () => { + const revalidatedData = { id: 3, name: 'Revalidated' }; + + mockFetchResponse(testUrl, { body: testData }); + + const { result } = renderHook(() => useFetcher(testUrl)); + + // Wait for initial data + await waitFor(() => { + expect(result.current.data).toEqual(testData); + }); + + // Update mock for revalidation + mockFetchResponse(testUrl, { body: revalidatedData }); + + const mutatedData = { id: 2, name: 'Mutated' }; + + // Call mutate with revalidate + await act(async () => { + await result.current.mutate(mutatedData, { refetch: true }); + }); + + // Should show revalidated data + await waitFor(() => { + expect(result.current.data).toEqual(revalidatedData); + }); + }); + }); + + describe('Configuration Options', () => { + it('should handle empty URL', () => { + const { result } = renderHook(() => useFetcher('')); + + expect(result.current.data).toBeNull(); + expect(result.current.error).toBeNull(); + expect(result.current.isLoading).toBe(false); + }); + + it('should handle null URL (dependent queries)', () => { + const { result } = renderHook(() => useFetcher(null)); + + expect(result.current.data).toBeNull(); + expect(result.current.error).toBeNull(); + expect(result.current.isLoading).toBe(false); + }); + + it('should handle custom configuration', async () => { + mockFetchResponse(testUrl, { body: testData }); + + const { result } = renderHook(() => + useFetcher(testUrl, { + method: 'POST', + body: { test: true }, + }), + ); + + // Initially should not be loading (POST doesn't auto-trigger) + expect(result.current.isLoading).toBe(false); + expect(result.current.data).toBeNull(); + + // Manually trigger the POST request + await act(async () => { + await result.current.refetch(); + }); + + await waitFor(() => { + expect(result.current.data).toEqual(testData); + }); + + // Verify request was made (we can't easily check method with mockFetchResponse) + expect(result.current.data).toEqual(testData); + }); + }); + + describe('Dependency Changes', () => { + it('should refetch when URL changes', async () => { + const newUrl = 'https://api.example.com/new-data'; + const newData = { id: 2, name: 'New' }; + + mockFetchResponse(testUrl, { body: testData }); + mockFetchResponse(newUrl, { body: newData }); + + const { result, rerender } = renderHook(({ url }) => useFetcher(url), { + initialProps: { url: testUrl }, + }); + + // Wait for initial data + await waitFor(() => { + expect(result.current.data).toEqual(testData); + }); + + // Change URL + rerender({ url: newUrl }); + + // Should fetch new data + await waitFor(() => { + expect(result.current.data).toEqual(newData); + }); + }); + }); + + describe('Performance', () => { + it('should not recreate refetch function unnecessarily', () => { + const { result, rerender, unmount } = renderHook(() => + useFetcher(testUrl), + ); + + const initialRefetch = result.current.refetch; + rerender(); + + expect(result.current.refetch).toBe(initialRefetch); + unmount(); + }); + + it('should not recreate mutate function unnecessarily', () => { + const { result, rerender, unmount } = renderHook(() => + useFetcher(testUrl), + ); + + const initialMutate = result.current.mutate; + rerender(); + + expect(result.current.mutate).toBe(initialMutate); + unmount(); + }); + }); + + describe('Loading States', () => { + it('should show loading when isFetching is true', async () => { + mockFetchResponse(testUrl, { body: testData }); + + const { result } = renderHook(() => useFetcher(testUrl)); + + // Should initially be loading + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + }); + + it('should show loading when no state and URL exists', async () => { + const { result } = renderHook(() => useFetcher(testUrl)); + + await waitFor(() => { + expect(result.current.isLoading).toBe(true); + }); + }); + + it('should not show loading when no URL', () => { + const { result } = renderHook(() => useFetcher('')); + + expect(result.current.isLoading).toBe(false); + }); + }); + + describe('Cache Key Generation', () => { + it('should use custom cache key when provided', async () => { + const customCacheKey = 'custom-key'; + const customCacheKeyFn = jest.fn().mockReturnValue(customCacheKey); + + mockFetchResponse(testUrl, { body: testData }); + + const { result } = renderHook(() => + useFetcher(testUrl, { cacheKey: customCacheKeyFn }), + ); + + await waitFor(() => { + expect(result.current.data).toEqual(testData); + }); + + // The custom cache key function should have been used + expect(customCacheKeyFn).toHaveBeenCalled(); + }); + + it('should regenerate cache key when dependencies change', async () => { + mockFetchResponse(testUrl, { body: testData }); + + const { result, rerender } = renderHook( + ({ method }: { method: 'GET' | 'POST' }) => + useFetcher(testUrl, { method }), + { initialProps: { method: 'GET' } }, + ); + + await waitFor(() => { + expect(result.current.data).toEqual(testData); + }); + + // Change method - should trigger new request + rerender({ method: 'POST' }); + + // Should clear data since cache key changed but POST doesn't auto-trigger + expect(result.current.data).toBeNull(); + expect(result.current.isLoading).toBe(false); + + // Manually trigger POST + await act(async () => { + await result.current.refetch(); + }); + + await waitFor(() => { + expect(result.current.data).toEqual(testData); + }); + }); + }); + + describe('Subscription Management', () => { + it('should handle subscription updates', async () => { + mockFetchResponse(testUrl, { body: testData }); + + const { result } = renderHook(() => useFetcher(testUrl)); + + await waitFor(() => { + expect(result.current.data).toEqual(testData); + }); + + const updatedData = { id: 2, name: 'Updated' }; + + // Simulate cache update via mutate + act(() => { + result.current.mutate(updatedData); + }); + + await waitFor(() => { + expect(result.current.data).toEqual(updatedData); + }); + }); + }); + + describe('Advanced Configuration', () => { + it('should handle timeout configuration', async () => { + mockFetchResponse(testUrl, { body: testData }); + + const { result } = renderHook(() => + useFetcher(testUrl, { timeout: 5000 }), + ); + + await waitFor(() => { + expect(result.current.data).toEqual(testData); + }); + }); + + it('should handle dedupeTime configuration', async () => { + mockFetchResponse(testUrl, { body: testData }); + + const { result } = renderHook(() => + useFetcher(testUrl, { dedupeTime: 5000 }), + ); + + await waitFor(() => { + expect(result.current.data).toEqual(testData); + }); + }); + + it('should handle cacheTime configuration', async () => { + mockFetchResponse(testUrl, { body: testData }); + + const { result } = renderHook(() => + useFetcher(testUrl, { cacheTime: 10000 }), + ); + + await waitFor(() => { + expect(result.current.data).toEqual(testData); + }); + }); + }); + + describe('Edge Cases', () => { + it('should handle empty cacheKey function', () => { + const emptyCacheKeyFn = jest.fn().mockReturnValue(''); + + renderHook(() => useFetcher(testUrl, { cacheKey: emptyCacheKeyFn })); + + // Should not break even with empty cache key + expect(emptyCacheKeyFn).toHaveBeenCalled(); + }); + + it('should handle config without cacheKey function', async () => { + mockFetchResponse(testUrl, { body: testData }); + + const { result } = renderHook(() => + // POST does not auto-trigger, so we use immediate: true + useFetcher(testUrl, { method: 'POST', immediate: true }), + ); + + await waitFor(() => { + expect(result.current.data).toEqual(testData); + }); + }); + + it('should handle rapid dependency changes', async () => { + const url1 = 'https://api.example.com/data1'; + const url2 = 'https://api.example.com/data2'; + const data1 = { id: 1, name: 'Data1' }; + const data2 = { id: 2, name: 'Data2' }; + + mockFetchResponse(url1, { body: data1 }); + mockFetchResponse(url2, { body: data2 }); + + const { result, rerender } = renderHook(({ url }) => useFetcher(url), { + initialProps: { url: url1 }, + }); + + await waitFor(() => { + expect(result.current.data).toEqual(data1); + }); + + // Rapidly change URLs + rerender({ url: url2 }); + rerender({ url: url1 }); + rerender({ url: url2 }); + + // Should handle gracefully and show final URL's data + await waitFor(() => { + expect(result.current.data).toEqual(data2); + }); + }); + + it('should not refetch when config object reference changes but content is same', async () => { + mockFetchResponse(testUrl, { body: testData }); + + const config1 = { method: 'GET' as const }; + const config2 = { method: 'GET' as const }; + + const { result, rerender } = renderHook( + ({ config }) => useFetcher(testUrl, config), + { initialProps: { config: config1 } }, + ); + + await waitFor(() => { + expect(result.current.data).toEqual(testData); + }); + + const initialData = result.current.data; + + rerender({ config: config2 }); + + // Should not trigger refetch since config content is the same + expect(result.current.data).toBe(initialData); + }); + }); + + describe('Suspense Integration', () => { + it('should throw promise when strategy is reject and pending promise exists', async () => { + mockFetchResponse(testUrl, { body: testData }); + + let thrown: unknown; + function ThrowCatcher() { + try { + // This should throw the promise synchronously when strategy is reject + useFetcher(testUrl, { strategy: 'reject' }); + } catch (e) { + thrown = e; + } + return null; + } + + renderHook(() => ThrowCatcher()); + + // For suspense, we expect a promise to be thrown + expect(thrown).toBeInstanceOf(Promise); + }); + + it('should not throw when strategy is not reject', async () => { + mockFetchResponse(testUrl, { body: testData }); + + expect(() => { + renderHook(() => useFetcher(testUrl, { strategy: 'softFail' })); + }).not.toThrow(); + + await act(async () => { + jest.runAllTimers(); + }); + }); + + it('should not throw when cached data exists', async () => { + mockFetchResponse(testUrl, { body: testData }); + + // First, populate the cache + const { result } = renderHook(() => useFetcher(testUrl)); + + await waitFor(() => { + expect(result.current.data).toEqual(testData); + }); + + // Then try with reject strategy - should not throw because data is cached + expect(() => { + renderHook(() => useFetcher(testUrl, { strategy: 'reject' })); + }).not.toThrow(); + }); + }); + + describe('Memory Management', () => { + it('should unsubscribe on unmount', async () => { + mockFetchResponse(testUrl, { body: testData }); + + const { result, unmount } = renderHook(() => useFetcher(testUrl)); + + await waitFor(() => { + expect(result.current.data).toEqual(testData); + }); + + // Unmount should not cause errors + expect(() => { + unmount(); + }).not.toThrow(); + }); + + it('should handle multiple subscriptions and unsubscriptions', async () => { + mockFetchResponse(testUrl, { body: testData }); + + const hook1 = renderHook(() => useFetcher(testUrl)); + const hook2 = renderHook(() => useFetcher(testUrl)); + const hook3 = renderHook(() => useFetcher(testUrl)); + + await waitFor(() => expect(hook1.result.current.data).toEqual(testData)); + await waitFor(() => expect(hook2.result.current.data).toEqual(testData)); + await waitFor(() => expect(hook3.result.current.data).toEqual(testData)); + + // Unmount in different orders + hook2.unmount(); + hook1.unmount(); + hook3.unmount(); + + // Should not cause any errors + expect(true).toBe(true); + }); + }); + + describe('Concurrent Requests', () => { + it('should handle multiple components using same URL', async () => { + // Use unique URL to avoid cache pollution + const multipleUrl = 'https://api.example.com/concurrent-multiple'; + mockFetchResponse(multipleUrl, { body: testData }); + + const hook1 = renderHook(() => useFetcher(multipleUrl)); + const hook2 = renderHook(() => useFetcher(multipleUrl)); + const hook3 = renderHook(() => useFetcher(multipleUrl)); + + // All should eventually get the same data + await waitFor(() => expect(hook1.result.current.data).toEqual(testData)); + await waitFor(() => expect(hook2.result.current.data).toEqual(testData)); + await waitFor(() => expect(hook3.result.current.data).toEqual(testData)); + + // All should be in sync + expect(hook1.result.current.data).toBe(hook2.result.current.data); + expect(hook2.result.current.data).toBe(hook3.result.current.data); + }); + + it('should handle mutation from one component affecting others', async () => { + // Use completely different unique URL + const mutationUrl = 'https://api.example.com/concurrent-mutation'; + mockFetchResponse(mutationUrl, { body: testData }); + + const hook1 = renderHook(() => useFetcher(mutationUrl)); + const hook2 = renderHook(() => useFetcher(mutationUrl)); + + await waitFor(() => expect(hook2.result.current.data).toEqual(testData)); + await waitFor(() => expect(hook1.result.current.data).toEqual(testData)); + + const mutatedData = { id: 99, name: 'Mutated' }; + + // Mutate from hook1 + act(() => { + hook1.result.current.mutate(mutatedData); + }); + + // Both hooks should reflect the mutation + await Promise.all([ + waitFor(() => expect(hook1.result.current.data).toEqual(mutatedData)), + waitFor(() => expect(hook2.result.current.data).toEqual(mutatedData)), + ]); + }); + }); + + describe('Error Recovery', () => { + it('should recover from error state with successful refetch', async () => { + const errorUrl = 'https://api.example.com/error-then-success'; + + // First request fails + mockFetchResponse(errorUrl, { ok: false, status: 500 }); + + const { result } = renderHook(() => useFetcher(errorUrl)); + + await waitFor(() => { + expect(result.current.error).toBeTruthy(); + expect(result.current.data).toBeNull(); + }); + + // Update mock to succeed + mockFetchResponse(errorUrl, { body: testData }); + + // Refetch should succeed + await act(async () => { + await result.current.refetch(); + }); + + await waitFor(() => { + expect(result.current.error).toBeNull(); + expect(result.current.data).toEqual(testData); + }); + }); + + it('should handle network errors gracefully', async () => { + const networkErrorUrl = 'https://api.example.com/network-error'; + + // Mock network error + mockFetchResponse(networkErrorUrl, { ok: false, status: 0 }); + + const { result } = renderHook(() => useFetcher(networkErrorUrl)); + + await waitFor(() => { + expect(result.current.error).toBeTruthy(); + expect(result.current.isLoading).toBe(false); + }); + }); + }); + + describe('Performance Edge Cases', () => { + it('should handle rapid mount/unmount cycles', async () => { + mockFetchResponse(testUrl, { body: testData }); + + // Rapidly mount and unmount hooks + for (let i = 0; i < 10; i++) { + const { unmount } = renderHook(() => useFetcher(testUrl)); + unmount(); + } + + // Final hook should still work + const { result } = renderHook(() => useFetcher(testUrl)); + + await waitFor(() => { + expect(result.current.data).toEqual(testData); + }); + }); + + it('should handle rapid configuration changes', async () => { + mockFetchResponse(testUrl, { body: testData }); + + const { result, rerender } = renderHook( + ({ config }) => useFetcher(testUrl, config), + { initialProps: { config: { method: 'GET' } } }, + ); + + // Rapidly change config + for (let i = 0; i < 5; i++) { + rerender({ config: { method: i % 2 === 0 ? 'GET' : 'POST' } }); + } + + await waitFor(() => { + expect(result.current.data).toEqual(testData); + }); + }); + }); +}); diff --git a/test/react/integration/authentication.spec.tsx b/test/react/integration/authentication.spec.tsx new file mode 100644 index 00000000..0fadec75 --- /dev/null +++ b/test/react/integration/authentication.spec.tsx @@ -0,0 +1,421 @@ +/** + * @jest-environment jsdom + */ +import '@testing-library/jest-dom'; +import { + act, + render, + screen, + waitFor, + fireEvent, +} from '@testing-library/react'; +import { useEffect, useState } from 'react'; +import { + clearMockResponses, + mockFetchResponse, +} from '../../utils/mockFetchResponse'; +import { useFetcher } from '../../../src/react/index'; +import { fetchf } from 'fetchff/index'; + +describe('Authentication Integration Tests', () => { + beforeEach(() => { + jest.useFakeTimers(); + localStorage.clear(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.resetAllMocks(); + clearMockResponses(); + }); + + describe('Login Flow', () => { + it('should handle successful login and token storage', async () => { + mockFetchResponse('/api/auth/login', { + body: { + user: { id: 1, email: 'user@example.com' }, + accessToken: 'access-token-123', + refreshToken: 'refresh-token-456', + }, + }); + + const LoginComponent = () => { + const [credentials] = useState({ + email: 'user@example.com', + password: 'password123', + }); + const [token, setToken] = useState(null); + + const { data, error, isLoading, refetch } = useFetcher<{ + user: { id: number; email: string }; + accessToken: string; + refreshToken: string; + }>('/api/auth/login', { + method: 'POST', + body: credentials, + immediate: false, + }); + + useEffect(() => { + if (data?.accessToken) { + setToken(data.accessToken); + localStorage.setItem('accessToken', data.accessToken); + localStorage.setItem('refreshToken', data.refreshToken); + } + }, [data]); + + return ( +
+
+ {isLoading ? 'Logging in...' : 'Not Loading'} +
+
+ {error ? error.message : 'No Error'} +
+
+ {data?.user ? JSON.stringify(data.user) : 'No User'} +
+
{token || 'No Token'}
+ +
+ ); + }; + + render(); + + // Initially no user data + expect(screen.getByTestId('user-data')).toHaveTextContent('No User'); + expect(screen.getByTestId('access-token')).toHaveTextContent('No Token'); + + // Click login + fireEvent.click(screen.getByTestId('login-button')); + + expect(screen.getByTestId('login-loading')).toHaveTextContent( + 'Logging in...', + ); + + // Should show user data after login + await waitFor(() => { + expect(screen.getByTestId('user-data')).toHaveTextContent( + 'user@example.com', + ); + expect(screen.getByTestId('access-token')).toHaveTextContent( + 'access-token-123', + ); + expect(screen.getByTestId('login-error')).toHaveTextContent('No Error'); + }); + + // Should store tokens in localStorage + expect(localStorage.getItem('accessToken')).toBe('access-token-123'); + expect(localStorage.getItem('refreshToken')).toBe('refresh-token-456'); + }); + + it('should handle login failure and display error', async () => { + mockFetchResponse('/api/auth/login', { + status: 401, + ok: false, + body: { message: 'Invalid credentials' }, + }); + + const LoginErrorComponent = () => { + const { data, error, refetch } = useFetcher('/api/auth/login', { + method: 'POST', + body: { email: 'wrong@example.com', password: 'wrongpass' }, + immediate: false, + strategy: 'softFail', + }); + + return ( +
+
+ {error ? `Error: ${error.status}` : 'No Error'} +
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+ +
+ ); + }; + + render(); + + fireEvent.click(screen.getByTestId('login-button')); + + expect(screen.getByTestId('login-data')).toHaveTextContent('No Data'); + + await waitFor(() => { + expect(screen.getByTestId('login-error')).toHaveTextContent( + 'Error: 401', + ); + expect(screen.getByTestId('login-data')).toHaveTextContent( + 'Invalid credentials', + ); + }); + }); + }); + + describe('Token Refresh', () => { + it('should refresh token when access token expires', async () => { + let requestCount = 0; + + global.fetch = jest.fn().mockImplementation((url) => { + requestCount++; + + if (url.includes('/api/profile') && requestCount === 1) { + // First request fails with 401 + return Promise.resolve({ + ok: false, + status: 401, + statusText: 'Unauthorized', + }); + } + + if (url.includes('/api/auth/refresh') && requestCount === 2) { + // Refresh token request + return Promise.resolve({ + ok: true, + status: 200, + data: { + accessToken: 'new-access-token-789', + refreshToken: 'new-refresh-token-012', + }, + }); + } + + if (url.includes('/api/profile') && requestCount > 2) { + // Retry with new token succeeds + return Promise.resolve({ + ok: true, + status: 200, + data: { id: 1, email: 'user@example.com', name: 'John Doe' }, + }); + } + + return Promise.reject(new Error('Unexpected request')); + }); + + const TokenRefreshComponent = () => { + const [accessToken, setAccessToken] = useState('expired-token'); + + const { data, error, refetch } = useFetcher('/api/profile', { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + immediate: false, + retry: { + retries: 1, + shouldRetry: async (response, attempt) => { + if (response.status === 401 && attempt === 0) { + // Refresh token + const { data: refreshData } = await fetchf( + '/api/auth/refresh', + { + method: 'POST', + headers: { + Authorization: `Bearer ${localStorage.getItem('refreshToken')}`, + }, + }, + ); + + if (refreshData?.accessToken) { + setAccessToken(refreshData.accessToken); + localStorage.setItem('accessToken', refreshData.accessToken); + + return true; // Retry with new token + } + } + return false; + }, + }, + onRetry(response) { + // Update headers with new access token on retry + response.config.headers = { + ...response.config.headers, + Authorization: `Bearer ${localStorage.getItem('accessToken')}`, + }; + }, + }); + + return ( +
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+
+ {error ? `Error: ${error.status}` : 'No Error'} +
+
{accessToken}
+ +
+ ); + }; + + // Set initial refresh token + localStorage.setItem('refreshToken', 'refresh-token-456'); + + render(); + + fireEvent.click(screen.getByTestId('load-profile')); + + await act(async () => { + jest.advanceTimersByTime(2000); // Simulate wait time between retries + }); + + // Should eventually show profile data with new token + await waitFor( + () => { + expect(screen.getByTestId('profile-data')).toHaveTextContent( + 'John Doe', + ); + expect(screen.getByTestId('current-token')).toHaveTextContent( + 'new-access-token-789', + ); + expect(screen.getByTestId('profile-error')).toHaveTextContent( + 'No Error', + ); + }, + { timeout: 2000 }, + ); + + expect(requestCount).toBeGreaterThanOrEqual(3); // 401 + refresh + retry + }); + }); + + describe('Protected Routes', () => { + it('should handle protected routes with authentication', async () => { + mockFetchResponse('/api/admin/users', { + body: { + users: [ + { id: 1, email: 'admin@example.com', role: 'admin' }, + { id: 2, email: 'user@example.com', role: 'user' }, + ], + }, + }); + + const ProtectedComponent = ({ + isAuthenticated, + }: { + isAuthenticated: boolean; + }) => { + const token = isAuthenticated ? 'valid-admin-token' : null; + + const { data, error, isLoading } = useFetcher('/api/admin/users', { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + immediate: !!token, + }); + + if (!isAuthenticated) { + return
Authentication Required
; + } + + return ( +
+
+ {isLoading ? 'Loading...' : 'Not Loading'} +
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+
+ {error ? error.message : 'No Error'} +
+
+ ); + }; + + // Test unauthenticated access + const { rerender } = render( + , + ); + + expect(screen.getByTestId('auth-required')).toHaveTextContent( + 'Authentication Required', + ); + + // Test authenticated access + rerender(); + + await waitFor(() => { + expect(screen.getByTestId('protected-data')).toHaveTextContent( + 'admin@example.com', + ); + expect(screen.getByTestId('protected-error')).toHaveTextContent( + 'No Error', + ); + }); + }); + }); + + describe('Logout Flow', () => { + it('should handle logout and clear tokens', async () => { + mockFetchResponse('/api/auth/logout', { + body: { message: 'Logged out successfully' }, + }); + + const LogoutComponent = () => { + const [isLoggedIn, setIsLoggedIn] = useState(true); + + const { data, error, refetch } = useFetcher('/api/auth/logout', { + method: 'POST', + immediate: false, + }); + + const handleLogout = async () => { + await refetch(); + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + setIsLoggedIn(false); + }; + + return ( +
+
+ {isLoggedIn ? 'Logged In' : 'Logged Out'} +
+
+ {data ? JSON.stringify(data) : 'No Response'} +
+
+ {error ? error.message : 'No Error'} +
+ +
+ ); + }; + + // Set initial tokens + localStorage.setItem('accessToken', 'access-token-123'); + localStorage.setItem('refreshToken', 'refresh-token-456'); + + render(); + + expect(screen.getByTestId('logout-status')).toHaveTextContent( + 'Logged In', + ); + + fireEvent.click(screen.getByTestId('logout-button')); + + await waitFor(() => { + expect(screen.getByTestId('logout-status')).toHaveTextContent( + 'Logged Out', + ); + expect(screen.getByTestId('logout-response')).toHaveTextContent( + 'Logged out successfully', + ); + }); + + // Should clear tokens from localStorage + expect(localStorage.getItem('accessToken')).toBeNull(); + expect(localStorage.getItem('refreshToken')).toBeNull(); + }); + }); +}); diff --git a/test/react/integration/error-handling.spec.tsx b/test/react/integration/error-handling.spec.tsx new file mode 100644 index 00000000..bf45f318 --- /dev/null +++ b/test/react/integration/error-handling.spec.tsx @@ -0,0 +1,524 @@ +/** + * @jest-environment jsdom + */ +import '@testing-library/jest-dom'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import React from 'react'; +import { + clearMockResponses, + createAbortableFetchMock, + mockFetchResponse, +} from '../../utils/mockFetchResponse'; +import { useFetcher } from '../../../src/react/index'; + +describe('Error Handling Integration Tests', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.resetAllMocks(); + clearMockResponses(); + }); + + describe('Network Errors', () => { + it('should handle network timeouts', async () => { + let abortSignal: AbortSignal | null = null; + + global.fetch = jest.fn().mockImplementation((url, options) => { + abortSignal = options?.signal as AbortSignal | null; + return createAbortableFetchMock(1000, true)(url, options); + }); + + const TimeoutComponent = () => { + const { data, error, isLoading } = useFetcher('/api/slow-endpoint', { + timeout: 1000, // 1 second timeout + }); + + return ( +
+
+ {isLoading ? 'Loading...' : 'Not Loading'} +
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+
+ {error ? error.message : 'No Error'} +
+
+ ); + }; + + render(); + + // Wait for request to start + await waitFor(() => { + expect(abortSignal).not.toBeNull(); + }); + + expect(screen.getByTestId('timeout-loading')).toHaveTextContent( + 'Loading...', + ); + + // Advance time to trigger timeout + jest.advanceTimersByTime(5000); + + await waitFor(() => { + expect(screen.getByTestId('timeout-error')).toHaveTextContent( + 'timeout', + ); + expect(screen.getByTestId('timeout-loading')).toHaveTextContent( + 'Not Loading', + ); + expect(screen.getByTestId('timeout-data')).toHaveTextContent('No Data'); + }); + }); + + it('should handle connection refused errors', async () => { + let abortSignal: AbortSignal | null = null; + + global.fetch = jest.fn().mockImplementation((url, options) => { + abortSignal = options?.signal as AbortSignal | null; + + return createAbortableFetchMock(0, true, { + ok: false, + status: 500, + })(url, options); + }); + + const ConnectionErrorComponent = () => { + const { data, error, isLoading, refetch } = useFetcher( + '/api/unreachable', + { + strategy: 'softFail', + cacheTime: 0, + }, + ); + + return ( +
+
+ {isLoading ? 'Loading...' : 'Not Loading'} +
+
+ {error ? error.message : 'No Error'} +
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+ +
+ ); + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('connection-error')).toHaveTextContent( + 'Status: 500', + ); + expect(screen.getByTestId('connection-data')).toHaveTextContent( + 'No Data', + ); + }); + + // Test retry functionality + fireEvent.click(screen.getByTestId('retry-button')); + expect(screen.getByTestId('connection-loading')).toHaveTextContent( + 'Loading...', + ); + jest.runAllTimers(); + + await waitFor(() => { + expect(abortSignal).not.toBeNull(); + }); + + await waitFor(() => { + expect(screen.getByTestId('connection-error')).toHaveTextContent( + 'Status: 500', + ); + }); + }); + }); + + describe('HTTP Status Errors', () => { + it('should handle 404 Not Found errors', async () => { + mockFetchResponse('/api/missing-resource', { + status: 404, + statusText: 'Not Found', + ok: false, + }); + + const NotFoundComponent = () => { + const { data, error, isLoading } = useFetcher('/api/missing-resource', { + strategy: 'softFail', + }); + + return ( +
+
+ {isLoading ? 'Loading...' : 'Not Loading'} +
+
+ {error ? `${error.status}: ${error.statusText}` : 'No Error'} +
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+
+ ); + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('not-found-error')).toHaveTextContent( + '404: Not Found', + ); + expect(screen.getByTestId('not-found-data')).toHaveTextContent( + 'No Data', + ); + expect(screen.getByTestId('not-found-loading')).toHaveTextContent( + 'Not Loading', + ); + }); + }); + + it('should handle 500 Internal Server Error', async () => { + mockFetchResponse('/api/server-error', { + status: 500, + ok: false, + }); + + const ServerErrorComponent = () => { + const { data, error, isLoading } = useFetcher('/api/server-error', { + strategy: 'softFail', + retry: { + retries: 2, + delay: 100, + }, + }); + + return ( +
+
+ {isLoading ? 'Loading...' : 'Not Loading'} +
+
+ {error ? `Status: ${error.status}` : 'No Status'} +
+
+ {error ? error.message : 'No Error'} +
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+
+ ); + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('server-error-status')).toHaveTextContent( + 'Status: 500', + ); + expect(screen.getByTestId('server-error-data')).toHaveTextContent( + 'No Data', + ); + }); + }); + }); + + describe('Error Recovery', () => { + it('should recover from temporary errors', async () => { + let requestCount = 0; + global.fetch = jest.fn().mockImplementation(() => { + requestCount++; + + if (requestCount <= 2) { + // First 2 requests fail + return Promise.resolve({ + ok: false, + status: 503, + statusText: 'Service Unavailable', + }); + } + + // Third request succeeds + return Promise.resolve({ + ok: true, + status: 200, + data: { message: 'Service recovered', attempt: requestCount }, + }); + }); + + const ErrorRecoveryComponent = () => { + const { data, error, isLoading } = useFetcher( + '/api/unreliable-service', + { + retry: { + retries: 3, + delay: 100, + backoff: 1.5, + }, + strategy: 'softFail', + }, + ); + + return ( +
+
+ {isLoading ? 'Loading...' : 'Not Loading'} +
+
+ {error ? `Error: ${error.status}` : 'No Error'} +
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+
{requestCount}
+
+ ); + }; + + render(); + + // Should eventually recover after retries + await waitFor( + () => { + expect(screen.getByTestId('recovery-data')).toHaveTextContent( + 'Service recovered', + ); + expect(screen.getByTestId('recovery-error')).toHaveTextContent( + 'No Error', + ); + }, + { timeout: 5000 }, + ); + + expect(requestCount).toBe(3); + }); + + it('should handle error boundaries with fetchf failures', async () => { + const originalConsoleError = console.error; + console.error = jest.fn(); + + mockFetchResponse('/api/critical-error', { + status: 500, + ok: false, + body: { message: 'Critical system failure' }, + }); + + class ErrorBoundary extends React.Component< + { children: React.ReactNode }, + { hasError: boolean; error?: Error } + > { + constructor(props: { children: React.ReactNode }) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(error: Error) { + return { hasError: true, error }; + } + + render() { + if (this.state.hasError) { + return ( +
+ Error Boundary Caught:{' '} + {this.state.error?.message || 'Unknown error'} +
+ ); + } + + return this.props.children; + } + } + + const CriticalErrorComponent = () => { + const { data, error } = useFetcher('/api/critical-error', { + strategy: 'reject', // This will throw on error + }); + + if (error) { + throw new Error(`Critical API Error: ${error.message}`); + } + + return ( +
+ {data ? JSON.stringify(data) : 'Loading...'} +
+ ); + }; + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('error-boundary')).toHaveTextContent( + 'Error Boundary Caught: Critical API Error', + ); + }); + + console.error = originalConsoleError; + }); + }); + + describe('Graceful Degradation', () => { + it('should provide fallback UI for failed requests', async () => { + mockFetchResponse('/api/user-profile', { + status: 500, + ok: false, + body: { message: 'Profile service unavailable' }, + }); + + const GracefulDegradationComponent = () => { + const { data, error, isLoading } = useFetcher('/api/user-profile', { + strategy: 'softFail', + }); + + const showFallback = error && !isLoading; + + return ( +
+ {isLoading && ( +
Loading profile...
+ )} + + {showFallback && ( +
+

Profile Unavailable

+

We're having trouble loading your profile right now.

+

Please try again later.

+
+ )} + + {data && !error && ( +
+

Welcome, {data.name}

+

{data.email}

+
+ )} + +
+ {error ? error.message : 'No Error'} +
+
+ ); + }; + + render(); + + // Should show loading first + expect(screen.getByTestId('loading-skeleton')).toHaveTextContent( + 'Loading profile...', + ); + + // Should show fallback UI on error + await waitFor(() => { + expect(screen.getByTestId('fallback-ui')).toHaveTextContent( + 'Profile Unavailable', + ); + expect( + screen.queryByTestId('loading-skeleton'), + ).not.toBeInTheDocument(); + expect(screen.queryByTestId('profile-data')).not.toBeInTheDocument(); + }); + }); + + it('should handle partial data loading failures', async () => { + // Mock successful main data but failed secondary data + mockFetchResponse('/api/dashboard', { + body: { + user: { name: 'John Doe', id: 1 }, + stats: { views: 1234, likes: 567 }, + }, + }); + + mockFetchResponse('/api/dashboard/notifications', { + status: 503, + ok: false, + body: { message: 'Notification service down' }, + }); + + const PartialLoadingComponent = () => { + const { data: dashboardData, error: dashboardError } = + useFetcher('/api/dashboard'); + + const { data: notificationsData, error: notificationsError } = + useFetcher('/api/dashboard/notifications', { strategy: 'softFail' }); + + return ( +
+
+ {dashboardData ? ( +
+

Welcome, {dashboardData.user.name}

+

Views: {dashboardData.stats.views}

+
+ ) : ( + 'Loading dashboard...' + )} +
+ +
+ {notificationsError ? ( +
+

Notifications

+

Notifications are temporarily unavailable

+
+ ) : notificationsData ? ( +
+

Notifications

+

{notificationsData.count} new notifications

+
+ ) : ( + 'Loading notifications...' + )} +
+ +
+ {dashboardError ? dashboardError.message : 'No Dashboard Error'} +
+
+ {notificationsError + ? notificationsError.message + : 'No Notifications Error'} +
+
+ ); + }; + + render(); + + // Should load main dashboard data successfully + await waitFor(() => { + expect(screen.getByTestId('dashboard-data')).toHaveTextContent( + 'Welcome, John Doe', + ); + expect(screen.getByTestId('dashboard-data')).toHaveTextContent( + 'Views: 1234', + ); + expect(screen.getByTestId('dashboard-error')).toHaveTextContent( + 'No Dashboard Error', + ); + }); + + // Should show fallback for failed notifications + await waitFor(() => { + expect(screen.getByTestId('notifications-section')).toHaveTextContent( + 'Notifications are temporarily unavailable', + ); + }); + }); + }); +}); diff --git a/test/react/integration/forms-crud.spec.tsx b/test/react/integration/forms-crud.spec.tsx new file mode 100644 index 00000000..6b33a7e6 --- /dev/null +++ b/test/react/integration/forms-crud.spec.tsx @@ -0,0 +1,682 @@ +/** + * @jest-environment jsdom + */ +import '@testing-library/jest-dom'; +import { + render, + screen, + waitFor, + fireEvent, + act, +} from '@testing-library/react'; +import { ChangeEvent, FormEvent, useEffect, useState } from 'react'; +import { + clearMockResponses, + mockFetchResponse, +} from '../../utils/mockFetchResponse'; +import { useFetcher } from '../../../src/react/index'; + +describe('Form & CRUD Integration Tests', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.resetAllMocks(); + clearMockResponses(); + }); + + describe('Form Submission', () => { + it('should handle form submission with validation', async () => { + global.fetch = jest.fn().mockImplementation(async (url, options) => { + if (url.includes('/api/users') && options?.method === 'POST') { + const body = JSON.parse(options.body as string); + + // Return validation error if name is empty or email is problematic + if (!body.name || body.email === 'existing@example.com') { + return Promise.resolve({ + ok: false, + status: 422, + data: { + // ✅ Direct data property + message: 'Validation failed', + errors: { + email: + body.email === 'existing@example.com' + ? 'Email is already taken' + : undefined, + name: !body.name ? 'Name is required' : undefined, + }, + }, + }); + } + + // Return success for valid data + return Promise.resolve({ + ok: true, + status: 201, + data: { + // ✅ Direct data property + id: 123, + name: body.name, + email: body.email, + createdAt: new Date().toISOString(), + }, + }); + } + + return Promise.reject(new Error('Unexpected request')); + }); + + const CreateUserForm = () => { + const [formData, setFormData] = useState({ + name: '', + email: '', + }); + const [submitAttempt, setSubmitAttempt] = useState(0); + + const { data, error, isLoading, refetch } = useFetcher<{ + id: number; + name: string; + email: string; + createdAt: string; + }>('/api/users', { + method: 'POST', + body: formData, + immediate: false, + strategy: 'softFail', + }); + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + setSubmitAttempt((prev) => prev + 1); + refetch(); + }; + + const updateField = (field: string, value: string) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + + return ( +
+ updateField('name', e.target.value)} + placeholder="Enter name" + /> + updateField('email', e.target.value)} + placeholder="Enter email" + /> + + +
+ {error ? JSON.stringify(error.message) : 'No Error'} +
+
+ {data ? `User created with ID: ${data.id}` : 'No Success'} +
+
{submitAttempt}
+
+ ); + }; + + render(); + + // Fill form with invalid data (will trigger validation error) + fireEvent.change(screen.getByTestId('name-input'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByTestId('email-input'), { + target: { value: 'existing@example.com' }, + }); + + // Submit form + fireEvent.click(screen.getByTestId('submit-button')); + + // Should show validation error + await waitFor(() => { + expect(screen.getByTestId('form-error')).toHaveTextContent('422'); + expect(screen.getByTestId('submit-count')).toHaveTextContent('1'); + }); + + // Fix validation issues and resubmit + fireEvent.change(screen.getByTestId('name-input'), { + target: { value: 'John Doe' }, + }); + fireEvent.change(screen.getByTestId('email-input'), { + target: { value: 'john@example.com' }, + }); + + fireEvent.click(screen.getByTestId('submit-button')); + + // Should show success + await waitFor(() => { + expect(screen.getByTestId('form-success')).toHaveTextContent( + 'User created with ID: 123', + ); + expect(screen.getByTestId('form-error')).toHaveTextContent('No Error'); + expect(screen.getByTestId('submit-count')).toHaveTextContent('2'); + }); + }); + + it('should handle file upload', async () => { + mockFetchResponse('/api/upload', { + method: 'POST', + body: { fileId: 'file-123', fileName: 'document.pdf' }, + }); + + const FileUpload = () => { + const [file, setFile] = useState(null); + const { data, isLoading, refetch } = useFetcher('/api/upload', { + method: 'POST', + body: file ? { file } : null, + immediate: false, + }); + + return ( +
+ setFile(e.target.files?.[0] || null)} + /> + +
+ {data ? `Uploaded: ${data.fileName}` : 'No Upload'} +
+
+ ); + }; + + render(); + + // Upload file + const mockFile = new File(['content'], 'document.pdf', { + type: 'application/pdf', + }); + const fileInput = screen.getByTestId('file-input'); + Object.defineProperty(fileInput, 'files', { value: [mockFile] }); + fireEvent.change(fileInput); + fireEvent.click(screen.getByTestId('upload-button')); + + await waitFor(() => { + expect(screen.getByTestId('result')).toHaveTextContent( + 'Uploaded: document.pdf', + ); + }); + }); + + it('should handle file upload with progress', async () => { + // Mock file upload response + mockFetchResponse('/api/upload', { + method: 'POST', + body: { + fileId: 'file-123', + fileName: 'document.pdf', + fileSize: 1024000, + uploadedAt: new Date().toISOString(), + }, + }); + + const FileUploadForm = () => { + const [selectedFile, setSelectedFile] = useState(null); + + const { data, error, isLoading, refetch } = useFetcher('/api/upload', { + method: 'POST', + body: selectedFile ? { file: selectedFile } : null, + immediate: false, + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + + const handleFileSelect = (e: ChangeEvent) => { + const file = e.target.files?.[0] || null; + setSelectedFile(file); + }; + + const handleUpload = () => { + if (selectedFile) { + refetch(); + } + }; + + return ( +
+ + + +
+ {isLoading ? 'Uploading' : 'Ready'} +
+
+ {data ? `Uploaded: ${data.fileName}` : 'No Upload'} +
+
+ {error ? error.message : 'No Error'} +
+
+ ); + }; + + render(); + + // Create mock file + const mockFile = new File(['mock file content'], 'document.pdf', { + type: 'application/pdf', + }); + + // Select file + const fileInput = screen.getByTestId('file-input'); + Object.defineProperty(fileInput, 'files', { + value: [mockFile], + }); + fireEvent.change(fileInput); + + // Upload file + fireEvent.click(screen.getByTestId('upload-button')); + + expect(screen.getByTestId('upload-status')).toHaveTextContent( + 'Uploading', + ); + + // Should show upload result + await waitFor(() => { + expect(screen.getByTestId('upload-result')).toHaveTextContent( + 'Uploaded: document.pdf', + ); + expect(screen.getByTestId('upload-status')).toHaveTextContent('Ready'); + }); + }); + }); + + describe('CRUD Operations', () => { + it('should handle complete CRUD workflow', async () => { + // Mock all CRUD operations + const users = [ + { id: 1, name: 'John Doe', email: 'john@example.com' }, + { id: 2, name: 'Jane Smith', email: 'jane@example.com' }, + ]; + + global.fetch = jest.fn().mockImplementation((url, options) => { + const method = options?.method || 'GET'; + + if (url === '/api/users' && method === 'GET') { + return Promise.resolve({ + ok: true, + status: 200, + data: { + users, + total: 2, + }, + }); + } + + if (url === '/api/users' && method === 'POST') { + return Promise.resolve({ + ok: true, + status: 201, + data: { id: 3, name: 'Bob Wilson', email: 'bob@example.com' }, + }); + } + + if (url === '/api/users/1' && method === 'PUT') { + return Promise.resolve({ + ok: true, + status: 200, + data: { + id: 1, + name: 'John Updated', + email: 'john.updated@example.com', + }, + }); + } + + if (url === '/api/users/2' && method === 'DELETE') { + return Promise.resolve({ + ok: true, + status: 200, + data: { message: 'User deleted successfully' }, + }); + } + + return Promise.reject( + new Error(`Unexpected request: ${method} ${url}`), + ); + }); + + const CRUDComponent = () => { + const [users, setUsers] = useState< + Array<{ id: number; name: string; email: string }> + >([]); + const [editingUser, setEditingUser] = useState<{ + id: number; + name: string; + email: string; + } | null>(null); + + // READ operation + const { data: usersData } = useFetcher<{ + users: Array<{ id: number; name: string; email: string }>; + total: number; + }>('/api/users'); + + useEffect(() => { + if (usersData?.users) { + setUsers(usersData.users); + } + }, [usersData]); + + // CREATE operation + const { data: createData, refetch: createUser } = useFetcher( + '/api/users', + { + method: 'POST', + body: { name: 'Bob Wilson', email: 'bob@example.com' }, + immediate: false, + }, + ); + + // UPDATE operation + const { data: updateData, refetch: updateUser } = useFetcher( + editingUser ? `/api/users/${editingUser.id}` : null, + { + method: 'PUT', + body: editingUser, + immediate: false, + }, + ); + + // DELETE operation + const { data: deleteData, refetch: deleteUser } = useFetcher( + '/api/users/2', + { + method: 'DELETE', + immediate: false, + }, + ); + + const handleCreate = () => { + createUser(); + }; + + const handleUpdate = () => { + setEditingUser({ + id: 1, + name: 'John Updated', + email: 'john.updated@example.com', + }); + setTimeout(() => updateUser(), 100); + }; + + const handleDelete = () => { + deleteUser(); + }; + + return ( +
+
+ {users.map((user) => ( +
+ {user.name} - {user.email} +
+ ))} +
+ + + + + +
+ {createData ? `Created: ${createData.name}` : 'No Create'} +
+
+ {updateData ? `Updated: ${updateData.name}` : 'No Update'} +
+
+ {deleteData ? deleteData.message : 'No Delete'} +
+
+ ); + }; + + render(); + + // Should load initial users (READ) + await waitFor(() => { + expect(screen.getByTestId('user-1')).toHaveTextContent( + 'John Doe - john@example.com', + ); + expect(screen.getByTestId('user-2')).toHaveTextContent( + 'Jane Smith - jane@example.com', + ); + }); + + // Test CREATE + fireEvent.click(screen.getByTestId('create-button')); + + await waitFor(() => { + expect(screen.getByTestId('create-result')).toHaveTextContent( + 'Created: Bob Wilson', + ); + }); + + // Test UPDATE + fireEvent.click(screen.getByTestId('update-button')); + + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + await waitFor(() => { + expect(screen.getByTestId('update-result')).toHaveTextContent( + 'Updated: John Updated', + ); + }); + + // Test DELETE + fireEvent.click(screen.getByTestId('delete-button')); + + await waitFor(() => { + expect(screen.getByTestId('delete-result')).toHaveTextContent( + 'User deleted successfully', + ); + }); + }); + }); + + describe('Optimistic Updates', () => { + it('should handle optimistic updates with rollback on error', async () => { + console.error = jest.fn(); + let updateAttempt = 0; + + global.fetch = jest.fn().mockImplementation((_url, options) => { + if (options?.method === 'PUT') { + updateAttempt++; + + if (updateAttempt === 1) { + // First update fails + return Promise.resolve({ + ok: false, + status: 500, + statusText: 'Server Error', + }); + } else { + // Second update succeeds + return Promise.resolve({ + ok: true, + status: 200, + data: { id: 1, name: 'John Updated', status: 'active' }, + }); + } + } + + // Initial data + return Promise.resolve({ + ok: true, + status: 200, + data: { id: 1, name: 'John Doe', status: 'active' }, + }); + }); + + const OptimisticUpdateComponent = () => { + const [localData, setLocalData] = useState<{ + id: number; + name: string; + status: string; + } | null>(null); + const [isOptimistic, setIsOptimistic] = useState(false); + + const { data: serverData } = useFetcher<{ + id: number; + name: string; + status: string; + }>('/api/user/1'); + + const { + data: updateData, + error, + isLoading, + refetch: updateUser, + } = useFetcher('/api/user/1', { + method: 'PUT', + body: { name: 'John Updated', status: 'active' }, + immediate: false, + strategy: 'softFail', + }); + + // Initialize with server data + useEffect(() => { + if (serverData && !localData) { + setLocalData(serverData); + } + }, [serverData, localData]); + + // Handle successful update + useEffect(() => { + if (updateData && !error) { + setLocalData(updateData); + setIsOptimistic(false); + } + }, [updateData, error]); + + // Handle error - rollback optimistic update + useEffect(() => { + if (error && isOptimistic) { + setLocalData(serverData); + setIsOptimistic(false); + } + }, [error, isOptimistic, serverData]); + + const handleOptimisticUpdate = () => { + // Optimistically update UI + setLocalData({ id: 1, name: 'John Updated', status: 'active' }); + setIsOptimistic(true); + + // Perform actual update + updateUser(); + }; + + return ( +
+
{localData?.name || 'Loading...'}
+
+ {isLoading ? 'Updating...' : 'Ready'} +
+
+ {isOptimistic ? 'Optimistic' : 'Server Data'} +
+
+ {error ? `Error: ${error.status}` : 'No Error'} +
+ +
+ ); + }; + + render(); + + // Should show initial data + await waitFor(() => { + expect(screen.getByTestId('user-name')).toHaveTextContent('John Doe'); + expect(screen.getByTestId('optimistic-indicator')).toHaveTextContent( + 'Server Data', + ); + }); + + // Perform optimistic update (will fail) + fireEvent.click(screen.getByTestId('update-button')); + + // Should immediately show optimistic update + expect(screen.getByTestId('user-name')).toHaveTextContent('John Updated'); + expect(screen.getByTestId('optimistic-indicator')).toHaveTextContent( + 'Optimistic', + ); + + // Should rollback on error + await waitFor(() => { + expect(screen.getByTestId('user-name')).toHaveTextContent('John Doe'); + expect(screen.getByTestId('optimistic-indicator')).toHaveTextContent( + 'Server Data', + ); + expect(screen.getByTestId('update-error')).toHaveTextContent( + 'Error: 500', + ); + }); + + // Try again (will succeed) + await act(async () => { + fireEvent.click(screen.getByTestId('update-button')); + + // Should eventually show successful update + await waitFor(() => { + expect(screen.getByTestId('user-name')).toHaveTextContent( + 'John Updated', + ); + expect(screen.getByTestId('update-error')).toHaveTextContent( + 'No Error', + ); + }); + }); + }); + }); +}); diff --git a/test/react/integration/hook.spec.tsx b/test/react/integration/hook.spec.tsx new file mode 100644 index 00000000..76aa5157 --- /dev/null +++ b/test/react/integration/hook.spec.tsx @@ -0,0 +1,1665 @@ +/** + * @jest-environment jsdom + */ +import '@testing-library/jest-dom'; +import { Suspense } from 'react'; +import { + render, + screen, + fireEvent, + waitFor, + act, + cleanup, +} from '@testing-library/react'; +import { useFetcher } from '../../../src/react/index'; +import { + clearMockResponses, + createAbortableFetchMock, + mockFetchResponse, +} from '../../utils/mockFetchResponse'; +import { + BasicComponent, + ErrorHandlingComponent, + SuspenseComponent, + MultipleRequestsComponent, + ConditionalComponent, + TestData, +} from '../../mocks/test-components'; +import { generateCacheKey } from 'fetchff/cache-manager'; +import { buildConfig } from 'fetchff/config-handler'; +import { getRefCount, getRefs } from 'fetchff/react/cache-ref'; +import React from 'react'; +import { clearAllTimeouts } from '../../../src/timeout-wheel'; + +describe('React Integration Tests', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.resetAllMocks(); + clearAllTimeouts(); + clearMockResponses(); + jest.clearAllTimers(); + }); + + describe('Basic Functionality', () => { + it('should render loading state initially and then data', async () => { + mockFetchResponse('/api/test', { body: { message: 'Hello World' } }); + + render(); + + // Should show loading initially + expect(screen.getByTestId('loading')).toHaveTextContent('Loading...'); + expect(screen.getByTestId('data')).toHaveTextContent('No Data'); + + // Should show data after fetch + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('Not Loading'); + expect(screen.getByTestId('data')).toHaveTextContent( + '{"message":"Hello World"}', + ); + }); + }); + + it('should handle null URL without making requests', () => { + mockFetchResponse('/api/null', { body: { message: 'Something' } }); + render(); + + expect(screen.getByTestId('loading')).toHaveTextContent('Not Loading'); + expect(screen.getByTestId('data')).toHaveTextContent('No Data'); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('should refetch when refetch button is clicked', async () => { + let callCount = 0; + global.fetch = jest.fn().mockImplementation(() => { + callCount++; + return Promise.resolve({ + ok: true, + status: 200, + body: { count: callCount }, + data: { count: callCount }, + }); + }); + + render(); + + // Wait for initial data + await waitFor(() => { + expect(screen.getByTestId('data')).toHaveTextContent('{"count":1}'); + }); + + // Click refetch + fireEvent.click(screen.getByTestId('refetch')); + + // Should show updated data + await waitFor(() => { + expect(screen.getByTestId('data')).toHaveTextContent('{"count":2}'); + }); + + expect(callCount).toBe(2); + }); + + it('should refetch when component remounts', async () => { + let callCount = 0; + global.fetch = jest.fn().mockImplementation(() => { + callCount++; + return Promise.resolve({ + ok: true, + status: 200, + body: { count: callCount }, + data: { count: callCount }, + }); + }); + + const { unmount } = render(); + + // Wait for initial fetch + await waitFor(() => { + expect(screen.getByTestId('data')).toHaveTextContent('{"count":1}'); + }); + + // Unmount the component + unmount(); + + // Remount the component + render(); + + await waitFor(() => { + expect(screen.getByTestId('data')).toHaveTextContent('{"count":1}'); + }); + + // Should have called fetch twice (once per mount) + expect(callCount).toBe(1); + }); + + it('should update data when mutate is called', async () => { + mockFetchResponse('/api/mutate', { body: { original: true } }); + + render(); + + // Wait for initial data + await waitFor(() => { + expect(screen.getByTestId('data')).toHaveTextContent( + '{"original":true}', + ); + }); + + // Click mutate + fireEvent.click(screen.getByTestId('mutate')); + + // Should show mutated data immediately + await waitFor(() => { + expect(screen.getByTestId('data')).toHaveTextContent( + '{"updated":true}', + ); + }); + + cleanup(); + }); + }); + + describe('Error Handling', () => { + it('should display error when fetch fails', async () => { + mockFetchResponse('/api/error', { status: 500, ok: false }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('error')).toHaveTextContent( + 'GET to /api/error failed! Status: 500', + ); + expect(screen.getByTestId('loading')).toHaveTextContent('Not Loading'); + }); + }); + + it('should handle network errors', async () => { + global.fetch = jest.fn().mockRejectedValue(new Error('Network Error')); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('error')).toHaveTextContent('Network Error'); + }); + }); + + it('should retry on failure and eventually succeed', async () => { + let attempts = 0; + global.fetch = jest.fn().mockImplementation(() => { + attempts++; + + if (attempts <= 2) { + return Promise.resolve({ + ok: false, + status: 500, + statusText: 'Server Error', + body: {}, + data: {}, + }); + } + return Promise.resolve({ + ok: true, + status: 200, + statusText: 'OK', + body: { success: true }, + data: { success: true }, + }); + }); + + render(); + + // Should eventually show success data after retries + await waitFor( + () => { + expect(screen.getByTestId('result-data')).toHaveTextContent( + '{"success":true}', + ); + }, + { timeout: 5000 }, + ); + + // Advance timers to handle retry delays + act(() => { + jest.advanceTimersByTime(5000); + }); + + expect(attempts).toBeGreaterThanOrEqual(3); + }); + }); + + describe('Suspense Integration', () => { + it('should work with Suspense boundaries', async () => { + mockFetchResponse('/api/suspense', { body: { suspense: 'works' } }); + + render( + Suspense Loading... + } + > + + , + ); + + // Should show Suspense fallback initially + expect(screen.getByTestId('suspense-loading')).toHaveTextContent( + 'Suspense Loading...', + ); + + // Should show data after loading + await waitFor(() => { + expect(screen.getByTestId('data')).toHaveTextContent( + '{"suspense":"works"}', + ); + }); + }); + + it('should handle errors in Suspense components', async () => { + mockFetchResponse('/api/suspense-error', { status: 404, ok: false }); + + render( + Loading...} + > + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('error')).toHaveTextContent( + 'Error: GET to /api/suspense-error failed! Status: 404', + ); + }); + }); + }); + + describe('Configuration Options', () => { + it('should handle custom headers', async () => { + mockFetchResponse('/api/headers', { headers: { something: 'received' } }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByTestId('headers')).toHaveTextContent('received'); + }); + + expect(global.fetch).toHaveBeenCalledWith( + '/api/headers', + expect.objectContaining({ + headers: expect.objectContaining({ 'X-Custom': 'custom-value' }), + }), + ); + }); + + it('should handle POST requests with body', async () => { + mockFetchResponse('/api/post', { + method: 'POST', + body: { created: true }, + }); + + render( + , + ); + + // POST requests should NOT auto-trigger + await waitFor(() => { + expect(screen.getByTestId('loading')).toHaveTextContent('Not Loading'); + expect(screen.getByTestId('data')).toHaveTextContent('No Data'); + }); + + // Click refetch + fireEvent.click(screen.getByTestId('refetch')); + + await waitFor(() => { + expect(screen.getByTestId('data')).toHaveTextContent( + '{"created":true}', + ); + }); + + expect(global.fetch).toHaveBeenCalledWith( + '/api/post', + expect.objectContaining({ method: 'POST' }), + ); + + expect(screen.getByTestId('config')).toHaveTextContent('"method":"POST"'); + }); + + it('should handle switching immediate from false to true', async () => { + mockFetchResponse('/api/immediate-switch', { body: { switched: true } }); + + const ImmediateSwitchComponent = ({ + immediate, + }: { + immediate: boolean; + }) => { + const { data, isLoading } = useFetcher( + '/api/immediate-switch', + { + immediate, + }, + ); + + return ( +
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+
+ {isLoading ? 'Loading' : 'Not Loading'} +
+
+ ); + }; + + const { rerender } = render( + , + ); + + // Should not make request when immediate is false + await waitFor(() => { + expect(screen.getByTestId('switch-loading')).toHaveTextContent( + 'Not Loading', + ); + expect(screen.getByTestId('switch-data')).toHaveTextContent('No Data'); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + // Switch to immediate: true + rerender(); + + // Should now make the request + await waitFor(() => { + expect(screen.getByTestId('switch-data')).toHaveTextContent( + '{"switched":true}', + ); + }); + + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('should handle URL path parameters', async () => { + mockFetchResponse('/api/users/123/posts', { + body: { + userId: 123, + posts: [], + }, + }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByTestId('data')).toHaveTextContent( + '{"userId":123,"posts":[]}', + ); + }); + + expect(global.fetch).toHaveBeenCalledWith( + '/api/users/123/posts', + expect.any(Object), + ); + }); + + it('should handle query parameters', async () => { + mockFetchResponse('/api/search?q=test%20query&limit=10', { + body: { query: 'processed' }, + }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByTestId('data')).toHaveTextContent( + '{"query":"processed"}', + ); + }); + + // Check that the URL contains query parameters + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('q=test%20query'), + expect.any(Object), + ); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('limit=10'), + expect.any(Object), + ); + }); + + it('should handle custom cache keys', async () => { + const customCacheKey = () => 'my-custom-key'; + mockFetchResponse('/api/custom-cache', { body: { cached: true } }); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByTestId('data')).toHaveTextContent('{"cached":true}'); + }); + }); + }); + + describe('Advanced Caching', () => { + it('should handle cache corruption recovery', async () => { + // First request + mockFetchResponse('/api/cache-test', { body: { version: 1 } }); + + const { unmount } = render(); + + await waitFor(() => { + expect(screen.getByTestId('data')).toHaveTextContent('version'); + }); + + unmount(); + + // Simulate cache corruption by directly modifying cache + // Then verify recovery on next request + mockFetchResponse('/api/cache-test', { body: { version: 2 } }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('data')).toHaveTextContent('version'); + }); + }); + + it('should handle cache size limits', async () => { + // Create many cache entries to test limits + for (let i = 0; i < 1000; i++) { + mockFetchResponse(`/api/cache-limit-${i}`, { body: { id: i } }); + const { unmount } = render( + , + ); + unmount(); + } + + // Verify cache doesn't grow indefinitely + const refs = getRefs(); + expect(refs.size).toBeLessThan(1000); // Should have been cleaned up + }); + }); + + describe('React Features', () => { + it('should work correctly in React Strict Mode', async () => { + mockFetchResponse('/api/strict', { body: { strict: true } }); + + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByTestId('data')).toHaveTextContent('strict'); + }); + + // In strict mode, effects run twice in development + // Verify no duplicate requests + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('should handle React 18 concurrent features', async () => { + mockFetchResponse('/api/concurrent?v=1', { body: { concurrent: true } }); + + const ConcurrentComponent = () => { + const [isPending, startTransition] = React.useTransition(); + const [url, setUrl] = React.useState('/api/concurrent?v=1'); + + const { data } = useFetcher(url); + + return ( +
+
+ {isPending ? 'Pending' : 'Not Pending'} +
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+ +
+ ); + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('data')).toHaveTextContent('concurrent'); + }); + }); + }); + + describe('Complex Scenarios', () => { + it('should handle multiple simultaneous different requests', async () => { + mockFetchResponse('/api/data-1', { body: { id: 1 } }); + mockFetchResponse('/api/data-2', { body: { id: 2 } }); + mockFetchResponse('/api/data-3', { body: { id: 3 } }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('data-1')).toHaveTextContent('{"id":1}'); + expect(screen.getByTestId('data-2')).toHaveTextContent('{"id":2}'); + expect(screen.getByTestId('data-3')).toHaveTextContent('{"id":3}'); + }); + + const fetchCalls = (global.fetch as jest.Mock).mock.calls; + expect(fetchCalls.length).toBe(3); + }); + + it('should handle conditional requests based on props', async () => { + mockFetchResponse('/api/conditional', { body: { conditional: true } }); + + const { rerender } = render(); + + // Should not make request when disabled + expect(screen.getByTestId('conditional-loading')).toHaveTextContent( + 'Not Loading', + ); + expect(global.fetch).not.toHaveBeenCalled(); + + // Should make request when enabled + rerender(); + + await waitFor(() => { + expect(screen.getByTestId('conditional-data')).toHaveTextContent( + '{"conditional":true}', + ); + }); + }); + + it('should handle strategy changes dynamically', async () => { + mockFetchResponse('/api/strategy', { status: 404, ok: false }); + + const StrategyComponent = ({ + strategy, + }: { + strategy: 'reject' | 'softFail'; + }) => { + const { data, error } = useFetcher('/api/strategy', { + strategy, + }); + return ( +
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+
+ {error ? error.message : 'No Error'} +
+
+ ); + }; + + const { rerender } = render(); + + await waitFor(() => { + expect(screen.getByTestId('strategy-error')).toHaveTextContent('404'); + }); + + // Change strategy + rerender(); + + // Should handle strategy change + expect(screen.getByTestId('strategy-error')).toHaveTextContent('404'); + }); + }); + + describe('Focus Revalidation', () => { + it('should revalidate on window focus', async () => { + let callCount = 0; + global.fetch = jest.fn().mockImplementation(() => { + callCount++; + return Promise.resolve({ + ok: true, + status: 200, + body: { focus: callCount }, + data: { focus: callCount }, + }); + }); + + const RevalidationComponent = () => { + const { data, isFetching } = useFetcher( + '/api/revalidate-data', + { + refetchOnFocus: true, + cacheTime: 5, + }, + ); + + return ( +
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+
+ {isFetching ? 'Revalidating...' : 'Not Revalidating'} +
+
+ ); + }; + + render(); + + // Initial request + await waitFor(() => { + expect(screen.getByTestId('revalidate-data')).toHaveTextContent( + '{"focus":1}', + ); + }); + + // Simulate window focus + act(() => { + window.dispatchEvent(new Event('focus')); + }); + + // Advance timers to process any debounced focus handling + act(() => { + jest.advanceTimersByTime(100); + }); + + // Should revalidate + await waitFor(() => { + expect(screen.getByTestId('revalidate-data')).toHaveTextContent( + '{"focus":2}', + ); + }); + + expect(callCount).toBeGreaterThanOrEqual(2); + }); + }); + + describe('Polling', () => { + it('should poll data at specified intervals', async () => { + let callCount = 0; + global.fetch = jest.fn().mockImplementation(() => { + callCount++; + return Promise.resolve({ + ok: true, + status: 200, + body: { poll: callCount }, + data: { poll: callCount }, + }); + }); + + const PollingComponent = ({ + interval, + shouldStop, + }: { + interval: number; + shouldStop: boolean; + }) => { + const { data, isFetching } = useFetcher('/api/poll-data', { + pollingInterval: interval, + shouldStopPolling: shouldStop ? () => true : undefined, + }); + + return ( +
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+
+ {isFetching ? 'Polling...' : 'Not Polling'} +
+
+ ); + }; + + render(); + + // Initial request + await waitFor(() => { + expect(screen.getByTestId('poll-data')).toHaveTextContent('{"poll":1}'); + }); + + // Advance timer for polling + act(() => { + jest.advanceTimersByTime(1000); + }); + + await waitFor(() => { + expect(screen.getByTestId('poll-data')).toHaveTextContent('{"poll":2}'); + }); + + expect(callCount).toBeGreaterThanOrEqual(2); + }); + }); + + describe('Complex Mixed Settings', () => { + it('should handle caching + retry + polling combined', async () => { + let callCount = 0; + global.fetch = jest.fn().mockImplementation(() => { + callCount++; + if (callCount <= 2) { + return Promise.resolve({ + ok: false, + status: 500, + statusText: 'Server Error', + }); + } + return Promise.resolve({ + ok: true, + status: 200, + body: { retryCount: callCount, cacheHit: false }, + data: { retryCount: callCount, cacheHit: false }, + }); + }); + + const ComplexComponent = () => { + const { data, error, isFetching } = useFetcher( + '/api/complex', + { + cacheTime: 10, + dedupeTime: 2, + refetchOnFocus: true, + pollingInterval: 1000, + retry: { retries: 3, delay: 100, backoff: 2 }, + cacheKey: 'complex-test', + }, + ); + + return ( +
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+
+ {error?.message || 'No Error'} +
+
+ {isFetching ? 'Validating' : 'Not Validating'} +
+
+ ); + }; + + await act(async () => { + render(); + }); + + // Should retry and eventually succeed + await waitFor(() => { + expect(screen.getByTestId('complex-data')).toHaveTextContent( + 'retryCount', + ); + }); + + // Advance timer for polling + await act(async () => { + jest.advanceTimersByTime(1000); + }); + + expect(callCount).toBeGreaterThanOrEqual(3); + }); + + it('should handle mixed strategies with different error handling', async () => { + mockFetchResponse('/api/reject', { status: 404, ok: false }); + mockFetchResponse('/api/softfail', { status: 500, ok: false }); + + const MixedComponent = () => { + const { data: rejectData, error: rejectError } = useFetcher( + '/api/reject', + { + strategy: 'reject', + cacheTime: 5, + }, + ); + + const { data: softFailData } = useFetcher('/api/softfail', { + strategy: 'softFail', + defaultResponse: { message: 'fallback' }, + retry: { retries: 2, delay: 50 }, + }); + + return ( +
+
+ {rejectData ? JSON.stringify(rejectData) : 'No Reject Data'} +
+
+ {rejectError?.message || 'No Reject Error'} +
+
+ {softFailData ? JSON.stringify(softFailData) : 'No SoftFail Data'} +
+
+ ); + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('reject-error')).toHaveTextContent('404'); + expect(screen.getByTestId('softfail-data')).toHaveTextContent( + 'fallback', + ); + }); + }); + + it('should handle cache mutations with dependencies', async () => { + mockFetchResponse('/api/users/123', { body: { id: 123, name: 'John' } }); + mockFetchResponse('/api/users/123/posts', { + body: { posts: ['post1', 'post2'] }, + }); + + const CacheComponent = () => { + const { data: user, mutate: mutateUser } = useFetcher( + '/api/users/123', + { + cacheTime: 30, + cacheKey: 'user-123', + }, + ); + + const { data: posts, mutate: mutatePosts } = useFetcher( + '/api/users/123/posts', + { + cacheTime: 15, + cacheKey: 'posts-123', + immediate: !!user, + }, + ); + + const updateUser = () => { + mutateUser({ ...user, name: 'Updated Name', mutated: true }); + mutatePosts({ ...posts, cached: true }); + }; + + return ( +
+
+ {user ? JSON.stringify(user) : 'No User'} +
+
+ {posts ? JSON.stringify(posts) : 'No Posts'} +
+ +
+ ); + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('user-data')).toHaveTextContent('John'); + }); + + fireEvent.click(screen.getByTestId('update-user')); + + expect(screen.getByTestId('user-data')).toHaveTextContent('Updated Name'); + expect(screen.getByTestId('user-data')).toHaveTextContent('mutated'); + }); + + it('should handle conditional requests with dynamic URLs', async () => { + mockFetchResponse('/api/users/456', { body: { id: 456, name: 'Jane' } }); + mockFetchResponse('/api/posts/789?include=comments', { + body: { id: 789, title: 'Post Title' }, + }); + + const DynamicComponent = ({ + type, + id, + enabled, + }: { + type: 'user' | 'post' | null; + id?: number; + enabled: boolean; + }) => { + const url = enabled && type && id ? `/api/${type}s/${id}` : null; + + const { data, isLoading } = useFetcher(url, { + cacheTime: type === 'user' ? 60 : 30, + dedupeTime: 5, + retry: { retries: type === 'user' ? 3 : 1, delay: 200 }, + params: type === 'post' ? { include: 'comments' } : undefined, + }); + + return ( +
+
{url || 'No URL'}
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+
+ {isLoading ? 'Loading' : 'Not Loading'} +
+
+ ); + }; + + const { rerender } = render( + , + ); + + expect(screen.getByTestId('dynamic-url')).toHaveTextContent('No URL'); + expect(global.fetch).not.toHaveBeenCalled(); + + rerender(); + + await waitFor(() => { + expect(screen.getByTestId('dynamic-data')).toHaveTextContent('Jane'); + }); + + rerender(); + + await waitFor(() => { + expect(screen.getByTestId('dynamic-data')).toHaveTextContent( + 'Post Title', + ); + }); + }); + + it('should handle overlapping requests with different configs', async () => { + mockFetchResponse('/api/overlap', { body: { phase: 'initial' } }); + + const OverlapComponent = ({ phase }: { phase: 1 | 2 | 3 }) => { + const config1 = { + cacheTime: phase === 1 ? 10 : 0, + dedupeTime: 1, + method: 'GET' as const, + }; + + const config2 = { + cacheTime: 20, + method: phase === 2 ? 'POST' : 'GET', + body: phase === 2 ? { data: 'test' } : undefined, + immediate: phase !== 2, + }; + + const { data: data1 } = useFetcher('/api/overlap', config1); + const { data: data2, refetch } = useFetcher( + '/api/overlap', + config2, + ); + + return ( +
+
+ {data1 ? JSON.stringify(data1) : 'No Data1'} +
+
+ {data2 ? JSON.stringify(data2) : 'No Data2'} +
+
{phase}
+ +
+ ); + }; + + const { rerender } = render(); + + await waitFor(() => { + expect(screen.getByTestId('overlap-data1')).toHaveTextContent( + 'initial', + ); + }); + + rerender(); + fireEvent.click(screen.getByTestId('overlap-refetch')); + + rerender(); + expect(screen.getByTestId('overlap-phase')).toHaveTextContent('3'); + }); + + it('should handle different error types with mixed configurations', async () => { + const ErrorComponent = ({ + errorType, + }: { + errorType: 'network' | '500' | '404' | 'success'; + }) => { + const getUrl = () => { + switch (errorType) { + case 'network': + return '/api/network-error'; + case '500': + return '/api/server-error'; + case '404': + return '/api/not-found'; + case 'success': + return '/api/success'; + } + }; + + const { data, error } = useFetcher(getUrl(), { + timeout: 5000, + retry: { + retries: errorType === '500' ? 3 : 1, + delay: 50, + retryOn: errorType === '404' ? [] : [500, 502, 503], + }, + strategy: errorType === '404' ? 'softFail' : 'reject', + defaultResponse: { error: true }, + }); + + return ( +
+
{errorType}
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+
+ {error?.message || 'No Error'} +
+
+ ); + }; + + // Test 404 with softFail + mockFetchResponse('/api/not-found', { status: 404, ok: false }); + const { rerender } = render(); + + await waitFor(() => { + expect(screen.getByTestId('error-data')).toHaveTextContent('error'); + }); + + // Test 500 with retries + mockFetchResponse('/api/server-error', { status: 500, ok: false }); + rerender(); + + await waitFor(() => { + expect(screen.getByTestId('error-message')).toHaveTextContent('500'); + }); + + // Test success + mockFetchResponse('/api/success', { body: { success: true } }); + rerender(); + + await waitFor(() => { + expect(screen.getByTestId('error-data')).toHaveTextContent('success'); + }); + }); + + it('should handle rapid config changes with cache invalidation', async () => { + let responseCount = 0; + global.fetch = jest.fn().mockImplementation(() => { + responseCount++; + return Promise.resolve({ + ok: true, + status: 200, + body: { count: responseCount, timestamp: Date.now() }, + data: { count: responseCount, timestamp: Date.now() }, + }); + }); + + const RapidComponent = ({ + config, + }: { + config: { cacheTime: number; dedupeTime: number }; + }) => { + const { data, refetch } = useFetcher('/api/rapid', { + ...config, + cacheKey: `rapid-${config.cacheTime}-${config.dedupeTime}`, + }); + + return ( +
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+ +
+ ); + }; + + const { rerender } = render( + , + ); + + await waitFor(() => { + expect(screen.getByTestId('rapid-data')).toHaveTextContent('count'); + }); + + // Change config rapidly + rerender(); + rerender(); + + fireEvent.click(screen.getByTestId('rapid-refetch')); + + await waitFor(() => { + expect(screen.getByTestId('rapid-data')).toHaveTextContent('count'); + }); + + expect(responseCount).toBeGreaterThan(1); + }); + }); + + describe('Configuration Validation', () => { + it('should handle invalid configuration gracefully', async () => { + const invalidConfigs = [ + { timeout: -1 }, + { timeout: 'invalid' }, + { retries: -1 }, + { cacheTime: 'invalid' }, + { headers: 'invalid' }, + { method: 'INVALID' }, + ]; + + for (const config of invalidConfigs) { + await act(async () => { + expect(() => + // @ts-expect-error Intentionally passing invalid config + render(), + ).not.toThrow(); + }); + } + }); + }); + + describe('Race Conditions', () => { + it('should handle rapid URL changes without race conditions', async () => { + let resolveCount = 0; + global.fetch = jest.fn().mockImplementation((url) => { + return new Promise((resolve) => { + setTimeout(() => { + resolveCount++; + resolve({ + ok: true, + status: 200, + body: { url, resolved: resolveCount }, + data: { url, resolved: resolveCount }, + }); + }, Math.random() * 100); // Random delay to simulate race conditions + }); + }); + + const { rerender } = render(); + + // Rapidly change URLs + rerender(); + rerender(); + rerender(); + + await waitFor(() => { + const data = screen.getByTestId('data').textContent; + // Should show data for the LAST URL only + expect(data).toContain('/api/race-4'); + expect(data).not.toContain('/api/race-1'); + expect(data).not.toContain('/api/race-2'); + expect(data).not.toContain('/api/race-3'); + }); + }); + + it('should handle concurrent requests to same URL', async () => { + let callCount = 0; + global.fetch = jest.fn().mockImplementation(() => { + callCount++; + return Promise.resolve({ + ok: true, + status: 200, + body: { concurrent: true, callCount }, + data: { concurrent: true, callCount }, + }); + }); + + // Render multiple components with same URL simultaneously + render( +
+ + + +
, + ); + + await waitFor(() => { + const dataElements = screen.getAllByTestId('data'); + dataElements.forEach((element) => { + expect(element).toHaveTextContent('concurrent'); + }); + }); + + // Should dedupe - only 1 actual fetch call + expect(callCount).toBe(1); + }); + }); + + describe('Memory Management', () => { + it('should cleanup subscriptions when component unmounts', async () => { + mockFetchResponse('/api/test', { body: { message: 'Hello World' } }); + + const { unmount } = render(); + + await waitFor(() => { + expect(screen.getByTestId('data')).toHaveTextContent('Hello World'); + }); + + const cacheKey = generateCacheKey(buildConfig('/api/test', {})); + + // Check specific cache key ref count + expect(getRefCount(cacheKey)).toBe(1); + + // Check it's in the global refs map + const refsBefore = getRefs(); + expect(refsBefore.has(cacheKey)).toBe(true); + expect(refsBefore.get(cacheKey)).toBe(1); + + unmount(); + + // Verify specific cleanup + expect(getRefCount(cacheKey)).toBe(0); + + // Verify it's removed from global map (or set to 0) + const refsAfter = getRefs(); + expect(refsAfter.get(cacheKey)).toBeUndefined(); + }); + + it('should not leak memory with rapid mount/unmount cycles', async () => { + const initialRefs = getRefs(); + const initialRefCount = Array.from(initialRefs.values()).reduce( + (sum, count) => sum + count, + 0, + ); + + for (let i = 0; i < 100; i++) { + const url = `/api/test-${i}`; + mockFetchResponse(url, { body: { id: i } }); + + const { unmount } = render(); + unmount(); + } + + const finalRefs = getRefs(); + const finalRefCount = Array.from(finalRefs.values()).reduce( + (sum, count) => sum + count, + 0, + ); + + // Should not accumulate any refs + expect(finalRefCount).toBe(initialRefCount); + + // Or more specifically, no refs should be > 0 + const activeRefs = Array.from(finalRefs.values()).filter( + (count) => count > 0, + ); + expect(activeRefs).toHaveLength(0); + }); + + it('should track multiple components using same cache key', async () => { + mockFetchResponse('/api/shared', { body: { shared: true } }); + + const { unmount: unmount1 } = render( + , + ); + const { unmount: unmount2 } = render( + , + ); + const { unmount: unmount3 } = render( + , + ); + + const cacheKey = generateCacheKey(buildConfig('/api/shared', {})); + + // Should have 3 references to the same cache key + expect(getRefCount(cacheKey)).toBe(3); + + const refs = getRefs(); + expect(refs.get(cacheKey)).toBe(3); + + // Unmount one component + unmount1(); + expect(getRefCount(cacheKey)).toBe(2); + + // Unmount second component + unmount2(); + expect(getRefCount(cacheKey)).toBe(1); + + // Unmount last component + unmount3(); + expect(getRefCount(cacheKey)).toBe(0); + }); + + it('should handle cache key collisions properly', async () => { + // Two different URLs that might generate same cache key (edge case) + mockFetchResponse('/api/test', { body: { source: 'first' } }); + mockFetchResponse('/api/test?v=1', { body: { source: 'second' } }); + + const { unmount: unmount1 } = render(); + const { unmount: unmount2 } = render( + , + ); + + const refs = getRefs(); + + // Should have proper ref counting even with different URLs + const totalActiveRefs = Array.from(refs.values()).reduce( + (sum, count) => sum + count, + 0, + ); + expect(totalActiveRefs).toBe(2); + + unmount1(); + unmount2(); + + const refsAfter = getRefs(); + const finalActiveRefs = Array.from(refsAfter.values()).filter( + (count) => count > 0, + ); + expect(finalActiveRefs).toHaveLength(0); + }); + + it('should handle rapid ref count changes without race conditions', async () => { + mockFetchResponse('/api/rapid', { body: { test: true } }); + + const components: Array<{ unmount: () => void }> = []; + + // Rapidly mount components + for (let i = 0; i < 50; i++) { + components.push(render()); + } + + const cacheKey = generateCacheKey(buildConfig('/api/rapid', {})); + expect(getRefCount(cacheKey)).toBe(50); + + // Rapidly unmount half + for (let i = 0; i < 25; i++) { + components.pop()?.unmount(); + } + + expect(getRefCount(cacheKey)).toBe(25); + + // Unmount the rest + components.forEach(({ unmount }) => unmount()); + + expect(getRefCount(cacheKey)).toBe(0); + }); + }); + + describe('Request Cancellation', () => { + it('should abort in-flight requests when component unmounts', async () => { + let abortSignal: AbortSignal | null = null; + // ✅ Mock that captures signal and responds to abort + global.fetch = jest.fn().mockImplementation((url, options) => { + abortSignal = options?.signal; + return createAbortableFetchMock(6000)(url, options); + }); + + const { unmount } = render(); + + // Wait for component to mount and request to start + await waitFor(() => { + expect(abortSignal).not.toBeNull(); + }); + + // Unmount immediately (synchronously) + unmount(); + + jest.advanceTimersByTime(3000); // Advance time to simulate dedupe time + + // Check that abort signal was triggered by unmount + expect((abortSignal as AbortSignal | null)?.aborted).toBe(true); + }); + + it('should handle timeout cancellation', async () => { + let abortSignal: AbortSignal | null = null; + + global.fetch = jest.fn().mockImplementation((url, options) => { + abortSignal = options?.signal as AbortSignal | null; + return createAbortableFetchMock(2000, true)(url, options); + }); + + render(); + + // Wait for request to start + await waitFor(() => { + expect(abortSignal).not.toBeNull(); + }); + + // Advance time past timeout + await act(async () => { + jest.advanceTimersByTime(150); + }); + + // Should be aborted and show error + expect((abortSignal as AbortSignal | null)?.aborted).toBe(true); + + await waitFor(() => { + expect(screen.getByTestId('error')).toHaveTextContent( + /timeout|aborted/i, + ); + }); + }); + + it('should NOT cancel requests when component stays mounted', async () => { + let abortSignal: AbortSignal | null = null; + let requestCompleted = false; + + global.fetch = jest.fn().mockImplementation((url, options) => { + abortSignal = options?.signal as AbortSignal | null; + return createAbortableFetchMock(1000, true)(url, options).then( + (result: unknown) => { + requestCompleted = true; + return result; + }, + ); + }); + + render(); + + // Wait for request to start + await waitFor(() => { + expect(abortSignal).not.toBeNull(); + }); + + // Initially not aborted + expect((abortSignal as AbortSignal | null)?.aborted).toBe(false); + + // Advance time to let request complete (but don't unmount) + await act(async () => { + jest.advanceTimersByTime(1200); + }); + + // Request should complete successfully without being aborted + expect((abortSignal as AbortSignal | null)?.aborted).toBe(false); + expect(requestCompleted).toBe(true); + + await waitFor(() => { + expect(screen.getByTestId('data')).toHaveTextContent( + '"completed":true', + ); + expect(screen.getByTestId('error')).toHaveTextContent('No Error'); + }); + }); + + it('should NOT timeout when request completes within timeout limit', async () => { + let abortSignal: AbortSignal | null = null; + + global.fetch = jest.fn().mockImplementation((url, options) => { + abortSignal = options?.signal as AbortSignal | null; + return createAbortableFetchMock(50, true)(url, options); // 50ms request, 150ms timeout + }); + + render(); + + // Wait for request to start + await waitFor(() => { + expect(abortSignal).not.toBeNull(); + }); + + // Advance time but less than timeout + await act(async () => { + jest.advanceTimersByTime(75); + }); + + // Should NOT be aborted + expect((abortSignal as AbortSignal | null)?.aborted).toBe(false); + + // Should show successful data, not timeout error + await waitFor(() => { + expect(screen.getByTestId('data')).toHaveTextContent( + '"completed":true', + ); + expect(screen.getByTestId('error')).toHaveTextContent('No Error'); + }); + }); + }); + + describe('Data Type Edge Cases', () => { + it('should handle various response data types', async () => { + const testCases = [ + { body: null, expected: 'No Data' }, + { body: 'null', expected: 'null' }, + { body: undefined, expected: 'No Data' }, + { body: '', expected: '""' }, + { body: 0, expected: '0' }, + { body: false, expected: 'false' }, + { body: [], expected: '[]' }, + { body: {}, expected: '{}' }, + { body: { nested: { deep: { value: 'test' } } }, expected: 'nested' }, + ]; + + for (let index = 0; index < testCases.length; index++) { + const testCase = testCases[index]; + mockFetchResponse(`/api/types-${index}`, testCase); + + const { unmount } = render( + , + ); + + await waitFor(() => { + const dataText = screen.getByTestId('data').textContent; + if (testCase.expected === 'No Data') { + expect(dataText).toBe('No Data'); + } else { + expect(dataText).toContain(testCase.expected); + } + }); + + // Clean up between iterations to avoid multiple elements + unmount(); + } + }); + + it('should handle circular reference objects in mutations', async () => { + mockFetchResponse('/api/circular', { body: { id: 1, name: 'test' } }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('data')).toHaveTextContent('test'); + }); + + // Create circular reference + const circularObj: Record = { id: 2, name: 'circular' }; + circularObj.self = circularObj; + + // Should not crash when trying to mutate with circular reference + expect(() => { + fireEvent.click(screen.getByTestId('mutate')); + }).not.toThrow(); + }); + }); + + describe('SSR/Hydration', () => { + it('should handle server-side rendering without window', async () => { + const originalWindow = global.window; + // @ts-expect-error Delete window to simulate SSR environment + delete global.window; + + await act(async () => { + expect(() => { + render(); + }).not.toThrow(); + }); + + global.window = originalWindow; + }); + }); + + describe('Browser API Edge Cases', () => { + it('should handle fetch API not available', async () => { + const originalFetch = global.fetch; + // @ts-expect-error Delete fetch to simulate no fetch API + delete global.fetch; + + await act(async () => { + expect(() => { + render(); + }).not.toThrow(); + }); + + global.fetch = originalFetch; + }); + + it('should work with service workers', async () => { + // Mock service worker interception + const originalFetch = global.fetch; + global.fetch = jest.fn().mockImplementation((url, options) => { + // Simulate service worker modifying request + if (url.includes('/api/sw')) { + return Promise.resolve({ + ok: true, + status: 200, + body: { serviceWorker: true, modified: true }, + data: { serviceWorker: true, modified: true }, + }); + } + return originalFetch(url, options); + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId('data')).toHaveTextContent('serviceWorker'); + }); + }); + + it('should handle localStorage/sessionStorage not available', async () => { + const originalLocalStorage = global.localStorage; + // @ts-expect-error Delete localStorage to simulate no storage + delete global.localStorage; + + await act(async () => { + expect(() => { + render(); + }).not.toThrow(); + }); + + global.localStorage = originalLocalStorage; + }); + }); +}); diff --git a/test/react/integration/pagination.spec.tsx b/test/react/integration/pagination.spec.tsx new file mode 100644 index 00000000..89fe4ad7 --- /dev/null +++ b/test/react/integration/pagination.spec.tsx @@ -0,0 +1,439 @@ +/** + * @jest-environment jsdom + */ +import '@testing-library/jest-dom'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import React, { act } from 'react'; +import { + clearMockResponses, + mockFetchResponse, +} from '../../utils/mockFetchResponse'; +import { useFetcher } from '../../../src/react/index'; +import { + ErrorPaginationComponent, + InfiniteScrollComponent, + PaginationComponent, + SearchPaginationComponent, +} from '../../mocks/test-components'; + +describe('React Pagination Integration Tests', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.resetAllMocks(); + clearMockResponses(); + }); + + describe('Pagination', () => { + it('should handle paginated data loading', async () => { + // Mock paginated responses + mockFetchResponse('/api/posts?page=1&limit=10', { + body: { + data: [ + { id: 1, title: 'Post 1' }, + { id: 2, title: 'Post 2' }, + { id: 3, title: 'Post 3' }, + ], + pagination: { + page: 1, + limit: 10, + total: 25, + totalPages: 3, + hasNext: true, + hasPrev: false, + }, + }, + }); + + mockFetchResponse('/api/posts?page=2&limit=10', { + body: { + data: [ + { id: 4, title: 'Post 4' }, + { id: 5, title: 'Post 5' }, + { id: 6, title: 'Post 6' }, + ], + pagination: { + page: 2, + limit: 10, + total: 25, + totalPages: 3, + hasNext: true, + hasPrev: true, + }, + }, + }); + + render(); + + // Should show loading initially + expect(screen.getByTestId('pagination-loading')).toHaveTextContent( + 'Loading', + ); + + // Should load first page + await waitFor(() => { + expect(screen.getByTestId('pagination-data')).toHaveTextContent( + 'Post 1', + ); + expect(screen.getByTestId('pagination-info')).toHaveTextContent( + 'Page 1 of 3', + ); + expect(screen.getByTestId('pagination-loading')).toHaveTextContent( + 'Not Loading', + ); + }); + + // Previous button should be disabled on first page + expect(screen.getByTestId('prev-page')).toBeDisabled(); + expect(screen.getByTestId('next-page')).not.toBeDisabled(); + + // Navigate to next page + fireEvent.click(screen.getByTestId('next-page')); + + // Should show loading for page 2 + expect(screen.getByTestId('pagination-loading')).toHaveTextContent( + 'Loading', + ); + + // Should load second page + await waitFor(() => { + expect(screen.getByTestId('pagination-data')).toHaveTextContent( + 'Post 4', + ); + expect(screen.getByTestId('pagination-info')).toHaveTextContent( + 'Page 2 of 3', + ); + expect(screen.getByTestId('current-page')).toHaveTextContent('2'); + }); + + // Both buttons should be enabled on middle page + expect(screen.getByTestId('prev-page')).not.toBeDisabled(); + expect(screen.getByTestId('next-page')).not.toBeDisabled(); + + // Verify both requests were made + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('page=1'), + expect.any(Object), + ); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('page=2'), + expect.any(Object), + ); + }); + + it('should handle infinite scroll pagination', async () => { + // Mock infinite scroll responses + mockFetchResponse('/api/feed?offset=0&limit=5', { + body: { + items: [ + { id: 1, content: 'Item 1' }, + { id: 2, content: 'Item 2' }, + { id: 3, content: 'Item 3' }, + { id: 4, content: 'Item 4' }, + { id: 5, content: 'Item 5' }, + ], + hasMore: true, + nextOffset: 5, + }, + }); + + mockFetchResponse('/api/feed?offset=5&limit=5', { + body: { + items: [ + { id: 6, content: 'Item 6' }, + { id: 7, content: 'Item 7' }, + { id: 8, content: 'Item 8' }, + ], + hasMore: false, + nextOffset: null, + }, + }); + + render(); + + // Should load initial items + await waitFor(() => { + expect(screen.getByTestId('item-1')).toHaveTextContent('Item 1'); + expect(screen.getByTestId('item-5')).toHaveTextContent('Item 5'); + expect(screen.getByTestId('items-count')).toHaveTextContent('5'); + expect(screen.getByTestId('has-more')).toHaveTextContent('Has More'); + }); + + // Load more items + fireEvent.click(screen.getByTestId('load-more')); + + expect(screen.getByTestId('infinite-loading')).toHaveTextContent( + 'Loading More', + ); + + await waitFor(() => { + expect(screen.getByTestId('item-6')).toHaveTextContent('Item 6'); + expect(screen.getByTestId('item-8')).toHaveTextContent('Item 8'); + expect(screen.getByTestId('items-count')).toHaveTextContent('8'); + expect(screen.getByTestId('has-more')).toHaveTextContent('No More'); + }); + + // Load more button should be disabled when no more data + expect(screen.getByTestId('load-more')).toBeDisabled(); + }); + + it('should handle pagination with search and filtering', async () => { + // Mock search responses + mockFetchResponse('/api/users?search=john&status=active&page=1&limit=3', { + body: { + users: [ + { id: 1, name: 'John Doe', status: 'active' }, + { id: 2, name: 'John Smith', status: 'active' }, + ], + pagination: { + page: 1, + limit: 3, + total: 2, + totalPages: 1, + }, + }, + }); + + mockFetchResponse('/api/users?search=jane&status=active&page=1&limit=3', { + body: { + users: [ + { id: 3, name: 'Jane Wilson', status: 'active' }, + { id: 4, name: 'Jane Brown', status: 'active' }, + ], + pagination: { + page: 1, + limit: 3, + total: 2, + totalPages: 1, + }, + }, + }); + + render(); + + // Should search for "john" initially + await waitFor(() => { + expect(screen.getByTestId('user-1')).toHaveTextContent( + 'John Doe - active', + ); + expect(screen.getByTestId('user-2')).toHaveTextContent( + 'John Smith - active', + ); + expect(screen.getByTestId('search-total')).toHaveTextContent( + 'Total: 2', + ); + }); + + // Change search term + fireEvent.change(screen.getByTestId('search-input'), { + target: { value: 'jane' }, + }); + + expect(screen.getByTestId('search-loading')).toHaveTextContent( + 'Searching', + ); + + // Should show new search results + await waitFor(() => { + expect(screen.getByTestId('user-3')).toHaveTextContent( + 'Jane Wilson - active', + ); + expect(screen.getByTestId('user-4')).toHaveTextContent( + 'Jane Brown - active', + ); + expect(screen.getByTestId('search-total')).toHaveTextContent( + 'Total: 2', + ); + expect(screen.getByTestId('search-page')).toHaveTextContent('Page: 1'); + }); + + // Verify both search queries were made + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('search=john'), + expect.any(Object), + ); + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining('search=jane'), + expect.any(Object), + ); + }); + + it('should handle pagination errors and retries', async () => { + let attemptCount = 0; + global.fetch = jest.fn().mockImplementation((url) => { + attemptCount++; + + if (url.includes('page=2') && attemptCount <= 2) { + // Fail first 2 attempts to page 2 + return Promise.resolve({ + ok: false, + status: 500, + statusText: 'Server Error', + }); + } + + if (url.includes('page=2')) { + // Succeed on 3rd attempt + return Promise.resolve({ + ok: true, + status: 200, + body: { + data: [{ id: 4, title: 'Post 4 (Retry Success)' }], + pagination: { + page: 2, + totalPages: 3, + hasNext: true, + hasPrev: true, + }, + }, + data: { + data: [{ id: 4, title: 'Post 4 (Retry Success)' }], + pagination: { + page: 2, + totalPages: 3, + hasNext: true, + hasPrev: true, + }, + }, + }); + } + + // Page 1 always succeeds + return Promise.resolve({ + ok: true, + status: 200, + body: { + data: [{ id: 1, title: 'Post 1' }], + pagination: { + page: 1, + totalPages: 3, + hasNext: true, + hasPrev: false, + }, + }, + data: { + data: [{ id: 1, title: 'Post 1' }], + pagination: { + page: 1, + totalPages: 3, + hasNext: true, + hasPrev: false, + }, + }, + }); + }); + + render(); + + // Should load page 1 successfully + await waitFor(() => { + expect(screen.getByTestId('error-pagination-data')).toHaveTextContent( + 'Post 1', + ); + expect(screen.getByTestId('error-pagination-error')).toHaveTextContent( + 'No Error', + ); + }); + + // Go to page 2 (will fail and retry) + fireEvent.click(screen.getByTestId('goto-page-2')); + + expect(screen.getByTestId('error-pagination-loading')).toHaveTextContent( + 'Loading', + ); + + // Should eventually succeed after retries + await waitFor( + () => { + expect(screen.getByTestId('error-pagination-data')).toHaveTextContent( + 'Retry Success', + ); + expect( + screen.getByTestId('error-pagination-error'), + ).toHaveTextContent('No Error'); + }, + { timeout: 5000 }, + ); + + // Advance timers for retry delays + act(() => { + jest.advanceTimersByTime(1000); + }); + + // Should have made multiple attempts + expect(attemptCount).toBeGreaterThanOrEqual(3); // 1 for page 1 + 2 for page 2 + }); + + it('should cache different pages independently', async () => { + mockFetchResponse('/api/cached-posts?page=1', { + body: { data: [{ id: 1, title: 'Cached Post 1' }], page: 1 }, + }); + + mockFetchResponse('/api/cached-posts?page=2', { + body: { data: [{ id: 2, title: 'Cached Post 2' }], page: 2 }, + }); + + const CachedPaginationComponent = () => { + const [page, setPage] = React.useState(1); + + const { data } = useFetcher(`/api/cached-posts`, { + params: { page }, + cacheTime: 3600, // Cache for 1 hour + cacheKey: `cached-posts-page-${page}`, + }); + + return ( +
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+ + +
{page}
+
+ ); + }; + + render(); + + // Load page 1 + await waitFor(() => { + expect(screen.getByTestId('cached-data')).toHaveTextContent( + 'Cached Post 1', + ); + }); + + // Switch to page 2 + fireEvent.click(screen.getByTestId('go-page-2')); + + await waitFor(() => { + expect(screen.getByTestId('cached-data')).toHaveTextContent( + 'Cached Post 2', + ); + expect(screen.getByTestId('cached-page')).toHaveTextContent('2'); + }); + + // Switch back to page 1 - should be cached (no additional fetch) + const fetchCallsBefore = (global.fetch as jest.Mock).mock.calls.length; + fireEvent.click(screen.getByTestId('go-page-1')); + + await waitFor(() => { + expect(screen.getByTestId('cached-data')).toHaveTextContent( + 'Cached Post 1', + ); + expect(screen.getByTestId('cached-page')).toHaveTextContent('1'); + }); + + // Should not have made additional fetch call (cached) + const fetchCallsAfter = (global.fetch as jest.Mock).mock.calls.length; + expect(fetchCallsAfter).toBe(fetchCallsBefore); + }); + }); +}); diff --git a/test/react/integration/performance-caching.spec.tsx b/test/react/integration/performance-caching.spec.tsx new file mode 100644 index 00000000..f0676d87 --- /dev/null +++ b/test/react/integration/performance-caching.spec.tsx @@ -0,0 +1,868 @@ +/** + * @jest-environment jsdom + */ +import '@testing-library/jest-dom'; +import { + render, + screen, + waitFor, + fireEvent, + act, +} from '@testing-library/react'; +import { memo, useEffect, useState } from 'react'; +import { + clearMockResponses, + mockFetchResponse, +} from '../../utils/mockFetchResponse'; +import { useFetcher } from '../../../src/react/index'; +import { clearAllTimeouts } from '../../../src/timeout-wheel'; + +describe('Performance & Caching Integration Tests', () => { + beforeEach(() => { + jest.useFakeTimers(); + localStorage.clear(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.resetAllMocks(); + clearAllTimeouts(); + jest.clearAllTimers(); + clearMockResponses(); + }); + + describe('Performance', () => { + it('should handle many simultaneous different requests efficiently', async () => { + jest.useRealTimers(); + const runs = 150; + + // Mock i different endpoints + for (let i = 0; i < runs; i++) { + mockFetchResponse(`/api/perf-${i}`, { body: { id: i } }); + } + + const startTime = performance.now(); + + const ManyRequestsComponent = () => { + const requests = Array.from({ length: runs }, (_, i) => { + const response = useFetcher(`/api/perf-${i}`, { + // cacheKey: 'key', + // timeout: 0, + // dedupeTime: 0, + }); + return response; + }); + + return ( +
+ {requests.filter(Boolean).length} loaded +
+ ); + }; + + render(); + + await waitFor(() => { + expect(screen.getByTestId('many-requests')).toHaveTextContent( + runs + ' loaded', + ); + }); + + const endTime = performance.now(); + // Should complete within reasonable time + // It is a basic performance test, not a strict benchmark + expect(endTime - startTime).toBeLessThan(130); + }); + + it('should not cause unnecessary rerenders with external store', async () => { + jest.useRealTimers(); + + let renderCount = 0; + let dataChangeCount = 0; + let previousData: unknown = null; + + mockFetchResponse('/api/stable-data', { + body: { message: 'Stable data', timestamp: Date.now() }, + }); + + const RenderTrackingComponent = memo(({ testId }: { testId: string }) => { + renderCount++; + + const { data, isLoading, error } = useFetcher('/api/stable-data', { + cacheTime: 30000, // Long cache time + staleTime: 15000, // Long stale time + }); + + // Track actual data changes (not just rerenders) + if (data !== previousData) { + dataChangeCount++; + previousData = data; + } + + return ( +
+
{renderCount}
+
+ {dataChangeCount} +
+
+ {data ? JSON.stringify(data) : 'Loading...'} +
+
+ {isLoading ? 'Loading' : 'Loaded'} +
+
+ {error ? 'Error' : 'No Error'} +
+
+ ); + }); + + const TestContainer = () => { + const [forceRerender, setForceRerender] = useState(0); + const [unrelatedState, setUnrelatedState] = useState('initial'); + + return ( +
+ + +
{forceRerender}
+
{unrelatedState}
+ + + + +
+ ); + }; + + const { rerender } = render(); + + // Wait for initial data load + await waitFor(() => { + expect(screen.getByTestId('stable-component-data')).toHaveTextContent( + 'Stable data', + ); + expect( + screen.getByTestId('stable-component-loading'), + ).toHaveTextContent('Loaded'); + }); + + // Get initial counts + const initialRenderCount = parseInt( + screen.getByTestId('stable-component-render-count').textContent || '0', + ); + const initialDataChangeCount = parseInt( + screen.getByTestId('stable-component-data-change-count').textContent || + '0', + ); + + // Test 1: Force parent rerenders should not cause child rerenders + fireEvent.click(screen.getByTestId('force-rerender-btn')); + fireEvent.click(screen.getByTestId('force-rerender-btn')); + fireEvent.click(screen.getByTestId('force-rerender-btn')); + + await waitFor(() => { + expect(screen.getByTestId('force-rerender-count')).toHaveTextContent( + '3', + ); + }); + + // useFetcher component should not have re-rendered due to parent rerenders + expect( + parseInt( + screen.getByTestId('stable-component-render-count').textContent || + '0', + ), + ).toBe(initialRenderCount); // Should be same as initial + + // Test 2: Changing unrelated state should not cause rerenders + fireEvent.click(screen.getByTestId('change-unrelated-btn')); + fireEvent.click(screen.getByTestId('change-unrelated-btn')); + + await waitFor(() => { + expect(screen.getByTestId('unrelated-state')).toHaveTextContent( + 'changed-', + ); + }); + + // Still should not have re-rendered + expect( + parseInt( + screen.getByTestId('stable-component-render-count').textContent || + '0', + ), + ).toBe(initialRenderCount); + + // Test 3: Manual rerender of entire tree should not cause unnecessary rerenders + rerender(); + rerender(); + + // Component should still not have re-rendered unnecessarily + expect( + parseInt( + screen.getByTestId('stable-component-render-count').textContent || + '0', + ), + ).toBe(initialRenderCount); + + // Test 4: Data should not have changed (only one real data change - initial load) + expect( + parseInt( + screen.getByTestId('stable-component-data-change-count') + .textContent || '0', + ), + ).toBe(initialDataChangeCount); // Should be 1 (initial load only) + + // Assertions for optimal performance + expect( + parseInt( + screen.getByTestId('stable-component-render-count').textContent || + '0', + ), + ).toBeLessThanOrEqual(initialRenderCount + 1); // Allow max 1 additional render + + expect( + parseInt( + screen.getByTestId('stable-component-data-change-count') + .textContent || '0', + ), + ).toBe(1); // Should only have 1 data change (initial load) + }); + }); + + describe('Cache Strategies', () => { + it('should implement stale-while-revalidate pattern', async () => { + let requestCount = 0; + const responses = [ + { data: 'Fresh data from server', timestamp: 1000 }, + { data: 'Updated data from server', timestamp: 2000 }, + ]; + + global.fetch = jest.fn().mockImplementation(() => { + const response = + responses[requestCount] || responses[responses.length - 1]; + requestCount++; + return Promise.resolve({ + ok: true, + status: 200, + data: response, + }); + }); + + const StaleWhileRevalidateComponent = () => { + const { data, isLoading } = useFetcher('/api/cached-data', { + cacheTime: 5000, // Cache for 5 seconds + staleTime: 2000, // Consider stale after 2 seconds + }); + + return ( +
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+
+ {isLoading ? 'Loading...' : 'Not Loading'} +
+
{requestCount}
+
+ ); + }; + + const { rerender } = render(); + + // Should show initial data + await waitFor(() => { + expect(screen.getByTestId('swr-data')).toHaveTextContent( + 'Fresh data from server', + ); + expect(screen.getByTestId('request-count')).toHaveTextContent('1'); + }); + + // Advance time past stale time but within cache time + jest.advanceTimersByTime(2500); + jest.runAllTimers(); + + // Re-render meanwhile (should not influence the results) + rerender(); + + // Data should remain unchanged while background revalidation starts + await act(async () => { + expect(screen.getByTestId('swr-data')).toHaveTextContent( + 'Fresh data from server', + ); + }); + + // Should eventually show updated data + await waitFor( + () => { + expect(screen.getByTestId('swr-data')).toHaveTextContent( + 'Updated data from server', + ); + expect(screen.getByTestId('request-count')).toHaveTextContent('2'); + }, + { timeout: 3000 }, + ); + }); + + it('should correctly invalidate cache and fetch new data', async () => { + let requestCount = 0; + global.fetch = jest.fn().mockImplementation(() => { + requestCount++; + return Promise.resolve({ + ok: true, + status: 200, + data: { message: `Request ${requestCount}`, id: requestCount }, + }); + }); + + const CacheInvalidationComponent = () => { + const [cacheKey, setCacheKey] = useState('user-data-1'); + + const { data, isLoading, refetch } = useFetcher('/api/user-data', { + cacheTime: 1000, // Long cache time + cacheKey, + }); + + const invalidateCache = () => { + setCacheKey(`user-data-${Date.now()}`); // New cache key = cache invalidation + }; + + return ( +
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+
+ {isLoading ? 'Loading...' : 'Not Loading'} +
+
{cacheKey}
+ + +
+ ); + }; + + render(); + + // Should load initial data + await waitFor(() => { + expect(screen.getByTestId('cache-data')).toHaveTextContent('Request 1'); + }); + + // Refetch should use cache (no new request) + fireEvent.click(screen.getByTestId('refetch-button')); + + await waitFor(() => { + expect(screen.getByTestId('cache-data')).toHaveTextContent('Request 1'); + }); + + expect(requestCount).toBe(1); // Still only 1 request + + // Invalidate cache should trigger new request + fireEvent.click(screen.getByTestId('invalidate-button')); + + await waitFor(() => { + expect(screen.getByTestId('cache-data')).toHaveTextContent('Request 2'); + }); + + expect(requestCount).toBe(2); // Now 2 requests + }); + }); + + describe('Request Deduplication', () => { + it('should deduplicate concurrent requests', async () => { + let requestCount = 0; + global.fetch = jest.fn().mockImplementation(() => { + requestCount++; + return new Promise((resolve) => { + setTimeout(() => { + resolve({ + ok: true, + status: 200, + data: { + message: 'Expensive operation result', + count: requestCount, + }, + }); + }, 1000); + }); + }); + + const DeduplicationComponent = ({ + instanceId, + }: { + instanceId: number; + }) => { + const { data, isLoading } = useFetcher('/api/expensive-operation', { + dedupeTime: 5000, // Dedupe for 5 seconds + }); + + return ( +
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+
+ {isLoading ? 'Loading...' : 'Not Loading'} +
+
+ ); + }; + + // Render multiple instances simultaneously + render( +
+ + + +
, + ); + + // All should show loading + expect(screen.getByTestId('dedupe-loading-1')).toHaveTextContent( + 'Loading...', + ); + expect(screen.getByTestId('dedupe-loading-2')).toHaveTextContent( + 'Loading...', + ); + expect(screen.getByTestId('dedupe-loading-3')).toHaveTextContent( + 'Loading...', + ); + + // Advance time to complete request + await act(async () => { + jest.advanceTimersByTime(1500); + }); + + // All should show same data (deduplication worked) + await waitFor( + () => { + expect(screen.getByTestId('dedupe-data-1')).toHaveTextContent( + 'count":1', + ); + expect(screen.getByTestId('dedupe-data-2')).toHaveTextContent( + 'count":1', + ); + expect(screen.getByTestId('dedupe-data-3')).toHaveTextContent( + 'count":1', + ); + }, + { timeout: 2000 }, + ); + + // Should have made only 1 request despite 3 components + expect(requestCount).toBe(1); + }); + + it('should handle multiple components with same cache key efficiently', async () => { + jest.useRealTimers(); + + const renderCounts = { comp1: 0, comp2: 0, comp3: 0 }; + let sharedFetchCount = 0; + + global.fetch = jest.fn().mockImplementation(() => { + sharedFetchCount++; + return Promise.resolve({ + ok: true, + status: 200, + data: { + shared: 'data', + fetchCount: sharedFetchCount, + timestamp: Date.now(), + }, + }); + }); + + const SharedCacheComponent = ({ + id, + }: { + id: keyof typeof renderCounts; + }) => { + renderCounts[id]++; + + const { data, isLoading } = useFetcher('/api/shared-cache', { + cacheTime: 100, + dedupeTime: 5000, + }); + + return ( +
+
{renderCounts[id]}
+
+ {data ? `${data.shared} (${data.fetchCount})` : 'Loading...'} +
+
+ {isLoading ? 'Loading' : 'Loaded'} +
+
+ ); + }; + + render( +
+ + + +
, + ); + + // Wait for all components to load + await waitFor(() => { + expect(screen.getByTestId('shared-comp1-data')).toHaveTextContent( + 'data (1)', + ); + expect(screen.getByTestId('shared-comp2-data')).toHaveTextContent( + 'data (1)', + ); + expect(screen.getByTestId('shared-comp3-data')).toHaveTextContent( + 'data (1)', + ); + }); + + // Should have made only 1 fetch despite 3 components + expect(sharedFetchCount).toBe(1); + + // Each component should have minimal renders + const comp1Renders = parseInt( + screen.getByTestId('shared-comp1-renders').textContent || '0', + ); + const comp2Renders = parseInt( + screen.getByTestId('shared-comp2-renders').textContent || '0', + ); + const comp3Renders = parseInt( + screen.getByTestId('shared-comp3-renders').textContent || '0', + ); + + // Each component should render minimally (initial + data update) + expect(comp1Renders).toBeLessThanOrEqual(3); + expect(comp2Renders).toBeLessThanOrEqual(3); + expect(comp3Renders).toBeLessThanOrEqual(3); + expect(sharedFetchCount).toBe(1); + + // All components should have same data + expect(screen.getByTestId('shared-comp1-data').textContent).toBe( + screen.getByTestId('shared-comp2-data').textContent, + ); + expect(screen.getByTestId('shared-comp2-data').textContent).toBe( + screen.getByTestId('shared-comp3-data').textContent, + ); + }); + + it('should only rerender when cache data actually changes', async () => { + let renderCount = 0; + let fetchCallCount = 0; + + // Mock different responses for each call + global.fetch = jest.fn().mockImplementation(() => { + fetchCallCount++; + return Promise.resolve({ + ok: true, + status: 200, + data: { + message: `Response ${fetchCallCount}`, + fetchNumber: fetchCallCount, + timestamp: Date.now(), + }, + }); + }); + + const CacheChangeComponent = () => { + renderCount++; + + const { data, refetch, mutate } = useFetcher('/api/cache-change-test', { + cacheTime: 100, + staleTime: 5000, + }); + + return ( +
+
{renderCount}
+
{fetchCallCount}
+
+ {data ? `${data.message} (${data.fetchNumber})` : 'Loading...'} +
+ + + + + + +
+ ); + }; + + render(); + + // Wait for initial load + await waitFor(() => { + expect(screen.getByTestId('cache-data')).toHaveTextContent( + 'Response 1 (1)', + ); + }); + + const getCurrentRenderCount = () => + Number(screen.getByTestId('cache-render-count').textContent || '0'); + const getCurrentFetchCount = () => + Number(screen.getByTestId('cache-fetch-count').textContent || '0'); + + let renderAfterInitialLoad = getCurrentRenderCount(); + + // Test 1: Cached refetch should NOT cause rerender (no data change) + fireEvent.click(screen.getByTestId('refetch-cached-btn')); + + await act(async () => { + jest.advanceTimersByTime(100); + }); + + expect(getCurrentRenderCount()).toBe(renderAfterInitialLoad); // Same render count + expect(getCurrentFetchCount()).toBe(1); // Same fetch count + + // Test 2: Force refetch SHOULD cause rerender (data changes) + fireEvent.click(screen.getByTestId('refetch-btn')); + + renderAfterInitialLoad++; // isFetching will increment this + + expect(getCurrentRenderCount()).toBe(renderAfterInitialLoad); + + await waitFor(() => { + expect(screen.getByTestId('cache-data')).toHaveTextContent( + 'Response 2 (2)', + ); + }); + + renderAfterInitialLoad++; // Data loaded will increment this + + expect(getCurrentRenderCount()).toBe(renderAfterInitialLoad); + expect(getCurrentFetchCount()).toBe(2); // One additional fetch + + let renderAfterRefetch = getCurrentRenderCount(); + + // Test 3: Mutate SHOULD cause rerender (data changes) + fireEvent.click(screen.getByTestId('mutate-btn')); + + await waitFor(() => { + expect(screen.getByTestId('cache-data')).toHaveTextContent( + 'Mutated data (999)', + ); + }); + + renderAfterRefetch++; // Mutate will increment this + + expect(getCurrentRenderCount()).toBe(renderAfterRefetch); // One additional render + expect(getCurrentFetchCount()).toBe(2); // Same fetch count (mutate doesn't fetch) + + // Final assertion: Renders should be minimal and only when data actually changes + // 1. Initial mount and data load (first fetch resolves so "isFetching" is already true as "immediate" is false). + // 2. isLoading state change (when refetch is triggered). + // 3. Data update after forced refetch (second fetch resolves). + // 4. Mutate call (local cache mutation triggers rerender). + // 5. Any additional state transitions (such as isLoading toggling back to false). + expect(getCurrentRenderCount()).toBeLessThanOrEqual( + renderAfterInitialLoad + renderAfterRefetch, + ); + expect(getCurrentRenderCount()).toBeLessThanOrEqual(5); + }); + }); + + describe('Memory Management', () => { + it('should clean up subscriptions on unmount', async () => { + mockFetchResponse('/api/cleanup-test', { + body: { message: 'Component data' }, + }); + + const CleanupComponent = () => { + const { data } = useFetcher('/api/cleanup-test'); + + return ( +
+ {data ? JSON.stringify(data) : 'Loading...'} +
+ ); + }; + + const { unmount } = render(); + + await waitFor(() => { + expect(screen.getByTestId('cleanup-data')).toHaveTextContent( + 'Component data', + ); + }); + + // Unmount component should not throw errors + expect(() => { + unmount(); + }).not.toThrow(); + + // Test passes if no memory leaks or cleanup errors occur + }); + + it('should handle component remounting with cache', async () => { + let requestCount = 0; + global.fetch = jest.fn().mockImplementation(() => { + requestCount++; + return Promise.resolve({ + ok: true, + status: 200, + data: { message: `Remount test ${requestCount}` }, + }); + }); + + const RemountComponent = () => { + const { data, isLoading } = useFetcher('/api/remount-test', { + cacheTime: 5000, + }); + + return ( +
+
+ {data ? JSON.stringify(data) : 'No Data'} +
+
+ {isLoading ? 'Loading...' : 'Not Loading'} +
+
+ ); + }; + + // Mount component + const { unmount } = render(); + + await waitFor(() => { + expect(screen.getByTestId('remount-data')).toHaveTextContent( + 'Remount test 1', + ); + }); + + expect(requestCount).toBe(1); + + // Unmount component + unmount(); + + // Wait a bit but not past cache time + await act(async () => { + jest.advanceTimersByTime(2000); + }); + + // Remount component + render(); + + // Should immediately show cached data without new request + expect(screen.getByTestId('remount-data')).toHaveTextContent( + 'Remount test 1', + ); + expect(requestCount).toBe(1); // Still only 1 request + }); + }); + + describe('Bundle Size Optimization', () => { + it('should lazy load non-critical data', async () => { + const essentialData = { user: 'John Doe', balance: 1000 }; + const nonCriticalData = { + recommendations: ['Product A', 'Product B'], + ads: ['Ad 1'], + }; + + mockFetchResponse('/api/essential', { + body: essentialData, + }); + + mockFetchResponse('/api/non-critical', { + body: nonCriticalData, + }); + + const LazyLoadComponent = () => { + const [loadNonCritical, setLoadNonCritical] = useState(false); + + // Essential data loads immediately + const { data: essential } = useFetcher('/api/essential'); + + // Non-critical data loads on demand + const { data: nonCritical } = useFetcher( + loadNonCritical ? '/api/non-critical' : null, + ); + + useEffect(() => { + // Load non-critical data after essential data is ready + if (essential) { + setTimeout(() => setLoadNonCritical(true), 500); + } + }, [essential]); + + return ( +
+
+ {essential ? `User: ${essential.user}` : 'Loading essential...'} +
+
+ {nonCritical + ? `Recommendations: ${nonCritical.recommendations.length}` + : 'Loading recommendations...'} +
+
+ {loadNonCritical ? 'Loading Non-Critical' : 'Essential Only'} +
+
+ ); + }; + + render(); + + // Should load essential data first + await waitFor(() => { + expect(screen.getByTestId('essential-data')).toHaveTextContent( + 'User: John Doe', + ); + expect(screen.getByTestId('load-state')).toHaveTextContent( + 'Essential Only', + ); + }); + + // Should start loading non-critical data + act(() => { + jest.advanceTimersByTime(600); + }); + + await waitFor(() => { + expect(screen.getByTestId('load-state')).toHaveTextContent( + 'Loading Non-Critical', + ); + }); + + // Should eventually load non-critical data + await waitFor(() => { + expect(screen.getByTestId('non-critical-data')).toHaveTextContent( + 'Recommendations: 2', + ); + }); + }); + }); +}); diff --git a/test/react/integration/realtime.spec.tsx b/test/react/integration/realtime.spec.tsx new file mode 100644 index 00000000..a9de7fc9 --- /dev/null +++ b/test/react/integration/realtime.spec.tsx @@ -0,0 +1,450 @@ +/** + * @jest-environment jsdom + */ +import '@testing-library/jest-dom'; +import { + render, + screen, + waitFor, + fireEvent, + act, +} from '@testing-library/react'; +import { useEffect, useState } from 'react'; +import { clearMockResponses } from '../../utils/mockFetchResponse'; +import { useFetcher } from '../../../src/react/index'; +import { removeRevalidators } from '../../../src/revalidator-manager'; + +describe('Real-time & WebSocket Integration Tests', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.resetAllMocks(); + clearMockResponses(); + }); + + describe('Polling', () => { + it('should handle automatic polling for real-time updates', async () => { + let pollCount = 0; + const mockResponses = [ + { notifications: [{ id: 1, message: 'First notification' }], count: 1 }, + { + notifications: [ + { id: 1, message: 'First notification' }, + { id: 2, message: 'Second notification' }, + ], + count: 2, + }, + { + notifications: [ + { id: 1, message: 'First notification' }, + { id: 2, message: 'Second notification' }, + { id: 3, message: 'Third notification' }, + ], + count: 3, + }, + ]; + + global.fetch = jest.fn().mockImplementation(() => { + const response = + mockResponses[pollCount] || mockResponses[mockResponses.length - 1]; + pollCount++; + return Promise.resolve({ + ok: true, + status: 200, + data: response, + }); + }); + + const PollingComponent = () => { + const { data, isLoading } = useFetcher<{ + notifications: Array<{ id: number; message: string }>; + count: number; + }>('/api/notifications', { + pollingInterval: 1000, // Poll every second + cacheTime: 0, // Don't cache polling data + }); + + return ( +
+
+ {isLoading ? 'Loading...' : 'Not Loading'} +
+
+ Count: {data?.count || 0} +
+
+ {data?.notifications?.map((notification) => ( +
+ {notification.message} +
+ )) || 'No Notifications'} +
+
+ ); + }; + + render(); + + // Should show initial data + await waitFor(() => { + expect(screen.getByTestId('notification-count')).toHaveTextContent( + 'Count: 1', + ); + expect(screen.getByTestId('notification-1')).toHaveTextContent( + 'First notification', + ); + }); + + // Advance timer to trigger next poll + act(() => { + jest.advanceTimersByTime(1000); + }); + + // Should show updated data after poll + await waitFor(() => { + expect(screen.getByTestId('notification-count')).toHaveTextContent( + 'Count: 2', + ); + expect(screen.getByTestId('notification-2')).toHaveTextContent( + 'Second notification', + ); + }); + + // Advance timer again + act(() => { + jest.advanceTimersByTime(1000); + }); + + // Should show third notification + await waitFor(() => { + expect(screen.getByTestId('notification-count')).toHaveTextContent( + 'Count: 3', + ); + expect(screen.getByTestId('notification-3')).toHaveTextContent( + 'Third notification', + ); + }); + + expect(pollCount).toBeGreaterThanOrEqual(3); + }); + + it('should stop polling when component unmounts', async () => { + let pollCount = 0; + global.fetch = jest.fn().mockImplementation(() => { + pollCount++; + return Promise.resolve({ + ok: true, + status: 200, + data: { timestamp: Date.now(), pollCount }, + }); + }); + + const PollingComponent = () => { + const { data } = useFetcher('/api/timestamp', { + pollingInterval: 500, + cacheTime: 0, + }); + + return ( +
{data?.timestamp || 'No Timestamp'}
+ ); + }; + + const { unmount } = render(); + + // Wait for initial poll + await waitFor(() => { + expect(screen.getByTestId('timestamp')).not.toHaveTextContent( + 'No Timestamp', + ); + }); + + const initialPollCount = pollCount; + + // Unmount component + unmount(); + + // Advance timers - should not trigger more polls + act(() => { + jest.advanceTimersByTime(2000); // 4 poll intervals + }); + + // Poll count should not increase after unmount + expect(pollCount).toBeLessThanOrEqual(initialPollCount + 1); + }); + }); + + describe('Manual Refresh', () => { + it('should handle manual refresh with revalidation', async () => { + let requestCount = 0; + global.fetch = jest.fn().mockImplementation(() => { + requestCount++; + return Promise.resolve({ + ok: true, + status: 200, + data: { + message: `Data updated ${requestCount} times`, + timestamp: Date.now() + requestCount * 1000, + }, + }); + }); + + const RefreshComponent = () => { + const { data, isLoading, refetch } = useFetcher<{ + message: string; + timestamp: number; + }>('/api/data', { + cacheTime: 5000, // Cache for 5 seconds + }); + + return ( +
+
{data?.message || 'No Data'}
+
+ {isLoading ? 'Loading...' : 'Not Loading'} +
+ +
+ ); + }; + + render(); + + // Should show initial data + await waitFor(() => { + expect(screen.getByTestId('refresh-data')).toHaveTextContent( + 'Data updated 1 times', + ); + }); + + // Click refresh + fireEvent.click(screen.getByTestId('refresh-button')); + + expect(screen.getByTestId('refresh-loading')).toHaveTextContent( + 'Loading...', + ); + + // Should show updated data + await waitFor(() => { + expect(screen.getByTestId('refresh-data')).toHaveTextContent( + 'Data updated 2 times', + ); + expect(screen.getByTestId('refresh-loading')).toHaveTextContent( + 'Not Loading', + ); + }); + + expect(requestCount).toBe(2); + }); + }); + + describe('Background Updates', () => { + afterEach(() => { + removeRevalidators('focus'); + }); + + it('should handle revalidation on window focus', async () => { + let requestCount = 0; + global.fetch = jest.fn().mockImplementation(() => { + requestCount++; + return Promise.resolve({ + ok: true, + status: 200, + data: { + content: `Content refreshed ${requestCount} times`, + lastUpdated: Date.now(), + }, + }); + }); + + const FocusRevalidationComponent = () => { + const { data } = useFetcher('/api/content', { + refetchOnFocus: true, + cacheTime: 1000, + }); + + return ( +
{data?.content || 'No Content'}
+ ); + }; + + render(); + + // Should show initial data + await waitFor(() => { + expect(screen.getByTestId('focus-content')).toHaveTextContent( + 'Content refreshed 1 times', + ); + }); + + // Simulate window focus event + act(() => { + window.dispatchEvent(new Event('focus')); + }); + + // Should revalidate on focus + await waitFor(() => { + expect(screen.getByTestId('focus-content')).toHaveTextContent( + 'Content refreshed 2 times', + ); + }); + + expect(requestCount).toBe(2); + }); + + it('should handle window focus revalidation', async () => { + let requestCount = 0; + + global.fetch = jest.fn().mockImplementation(() => { + requestCount++; + + return Promise.resolve({ + ok: true, + status: 200, + data: { + status: `Updated ${requestCount} times`, + timestamp: Date.now(), + }, + }); + }); + + const FocusComponent = () => { + const { data } = useFetcher('/api/status', { + refetchOnFocus: true, + cacheTime: 500, + }); + + return ( +
{data?.status || 'No Status'}
+ ); + }; + + render(); + + // Should show initial data + await waitFor(() => { + expect(screen.getByTestId('focus-status')).toHaveTextContent( + 'Updated 1 times', + ); + }); + + // Simulate window focus (user returns to tab) + act(() => { + window.dispatchEvent(new Event('focus')); + }); + + // Should revalidate on focus + await waitFor(() => { + expect(screen.getByTestId('focus-status')).toHaveTextContent( + 'Updated 2 times', + ); + }); + + expect(requestCount).toBe(2); + }); + }); + + describe('Long Polling', () => { + it('should handle long polling with timeout and retry', async () => { + let pollAttempt = 0; + const pollResponses = [ + { hasUpdate: false, data: 'No updates' }, + { hasUpdate: false, data: 'Still no updates' }, + { hasUpdate: true, data: 'New update available!' }, + ]; + + global.fetch = jest.fn().mockImplementation(() => { + const response = + pollResponses[pollAttempt] || pollResponses[pollResponses.length - 1]; + pollAttempt++; + + return new Promise((resolve) => { + // Simulate long polling with delay + setTimeout( + () => { + resolve({ + ok: true, + status: 200, + data: response, + }); + }, + response.hasUpdate ? 100 : 2000, + ); // Quick response when update available + }); + }); + + const LongPollingComponent = () => { + const [shouldPoll, setShouldPoll] = useState(true); + const [updates, setUpdates] = useState([]); + + const { data, isLoading } = useFetcher<{ + hasUpdate: boolean; + data: string; + }>('/api/long-poll', { + immediate: shouldPoll, + pollingInterval: 1000, // Poll every second + cacheTime: 0, + }); + + useEffect(() => { + if (data?.hasUpdate) { + setUpdates((prev) => [...prev, data.data]); + setShouldPoll(false); // Stop polling after getting update + } + }, [data]); + + return ( +
+
+ {isLoading ? 'Polling...' : 'Not Polling'} +
+
{data?.data || 'No Data'}
+
+ {updates.map((update, index) => ( +
+ {update} +
+ )) || 'No Updates'} +
+ +
+ ); + }; + + render(); + + // Should start polling + expect(screen.getByTestId('long-poll-loading')).toHaveTextContent( + 'Polling...', + ); + + // Should eventually get the update + await waitFor( + () => { + expect(screen.getByTestId('update-0')).toHaveTextContent( + 'New update available!', + ); + expect(screen.getByTestId('long-poll-loading')).toHaveTextContent( + 'Not Polling', + ); + }, + { timeout: 10000 }, + ); + + expect(pollAttempt).toBeGreaterThanOrEqual(3); + }); + }); +}); diff --git a/test/request-handler.spec.ts b/test/request-handler.spec.ts index 91356730..dc28f2ee 100644 --- a/test/request-handler.spec.ts +++ b/test/request-handler.spec.ts @@ -1,13 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { createRequestHandler } from '../src/request-handler'; import fetchMock from 'fetch-mock'; import * as interceptorManager from '../src/interceptor-manager'; import { delayInvocation } from '../src/utils'; -import type { - RequestConfig, - RequestHandlerReturnType, -} from '../src/types/request-handler'; -import { fetchf } from '../src'; +import type { RequestConfig } from '../src/types/request-handler'; +import { fetchf, setDefaultConfig } from '../src'; import { ABORT_ERROR } from '../src/constants'; import { pruneCache } from '../src/cache-manager'; @@ -20,9 +16,7 @@ jest.mock('../src/utils', () => { }; }); -const fetcher = { - create: jest.fn().mockReturnValue({ request: jest.fn() }), -}; +let fetcher = jest.fn(); fetchMock.mockGlobal(); @@ -59,28 +53,15 @@ describe('Request Handler', () => { afterEach(() => { fetchMock.clearHistory(); fetchMock.removeRoutes(); + jest.runAllTimers(); jest.useRealTimers(); - }); - - it('should get request instance', () => { - const requestHandler = createRequestHandler({ fetcher }); - - const response = requestHandler.getInstance(); - - expect(response).toBeTruthy(); + fetcher = jest.fn(); }); it('should properly hang promise when using Silent strategy', async () => { - const requestHandler = createRequestHandler({ - fetcher, - strategy: 'silent', - }); + fetcher = jest.fn().mockRejectedValue(new Error('Request Failed')); - (requestHandler.getInstance() as any).request = jest - .fn() - .mockRejectedValue(new Error('Request Failed')); - - const request = requestHandler.request(apiUrl); + const request = fetchf(apiUrl, { fetcher, strategy: 'silent' }); const timeout = new Promise((resolve) => { const wait = setTimeout(() => { @@ -100,17 +81,14 @@ describe('Request Handler', () => { }); it('should reject promise when using rejection strategy', async () => { - const requestHandler = createRequestHandler({ - fetcher, - strategy: 'reject', - }); - - (requestHandler.getInstance() as any).request = jest - .fn() - .mockRejectedValue(new Error('Request Failed')); + fetchMock.getOnce(apiUrl, 500); try { - const response = await (requestHandler as any).delete(apiUrl); + const response = await fetchf(apiUrl, { + fetcher, + strategy: 'reject', + method: 'DELETE', + }); expect(response).toBe(undefined); } catch (error) { expect(typeof error).toBe('object'); @@ -118,47 +96,37 @@ describe('Request Handler', () => { }); it('should reject promise when using reject strategy per endpoint', async () => { - const requestHandler = createRequestHandler({ - fetcher, - strategy: 'silent', - }); - - (requestHandler.getInstance() as any).request = jest - .fn() - .mockRejectedValue(new Error('Request Failed')); + fetchMock.getOnce(apiUrl, 500); try { - await requestHandler.request(apiUrl, { - strategy: 'reject', - }); + await fetchf(apiUrl, { fetcher, strategy: 'reject' }); } catch (error) { expect(typeof error).toBe('object'); } }); it('should use custom fetcher instance if provided', async () => { - const customFetcher = { - create: jest.fn().mockReturnValue({ - request: jest.fn().mockResolvedValue({ data: { foo: 'bar' } }), - }), - }; - const handler = createRequestHandler({ fetcher: customFetcher }); - const result = await handler.request('http://example.com/api/custom'); - expect(customFetcher.create).toHaveBeenCalled(); - expect(result.data).toEqual({ foo: 'bar' }); + const customFetcher = jest + .fn() + .mockResolvedValue({ data: { foo: 'bar' } }); + + const { data } = await fetchf('http://example.com/api/custom', { + fetcher: customFetcher, + }); + expect(customFetcher).toHaveBeenCalled(); + expect(data).toEqual({ data: { foo: 'bar' } }); }); it('should abort request on timeout', async () => { - const handler = createRequestHandler({ - timeout: 1000, - rejectCancelled: true, - }); fetchMock.get( 'http://example.com/api/timeout', () => new Promise(() => {}), ); // never resolves - const promise = handler.request('http://example.com/api/timeout'); + const promise = fetchf('http://example.com/api/timeout', { + timeout: 1000, + rejectCancelled: true, + }); jest.advanceTimersByTime(1100); // advance enough for timeout to trigger await expect(promise).rejects.toThrow(); }); @@ -169,12 +137,14 @@ describe('Request Handler', () => { callCount++; return { status: 200, body: { foo: 'bar' } }; }); - const handler = createRequestHandler({ + await fetchf('http://example.com/api/cache-buster', { + cacheTime: 60, + cacheBuster: () => true, + }); + await fetchf('http://example.com/api/cache-buster', { cacheTime: 60, cacheBuster: () => true, }); - await handler.request('http://example.com/api/cache-buster'); - await handler.request('http://example.com/api/cache-buster'); expect(callCount).toBe(2); }); }); @@ -198,13 +168,6 @@ describe('Request Handler', () => { }); it('should handle polling with shouldStopPolling always false (infinite loop protection)', async () => { - const handler = createRequestHandler({ - pollingInterval: 10, - shouldStopPolling: () => false, - retry: { retries: 0 }, - maxPollingAttempts: 10, - }); - let callCount = 0; (globalThis.fetch as jest.Mock) = jest.fn().mockImplementation(() => { @@ -218,7 +181,12 @@ describe('Request Handler', () => { ); }); - const promise = handler.request('http://example.com/api/poll'); + const promise = fetchf('http://example.com/api/poll', { + pollingInterval: 10, + shouldStopPolling: () => false, + retry: { retries: 0 }, + maxPollingAttempts: 10, + }); // Advance timers in steps and allow microtasks to run for (let i = 0; i < 10; i++) { @@ -242,16 +210,6 @@ describe('Request Handler', () => { }), }; - // Initialize RequestHandler with polling configuration - const requestHandler = createRequestHandler({ - baseURL, - retry: { - retries: 0, // No retries for this test - }, - ...pollingConfig, - logger: mockLogger, - }); - // Mock fetch to return a successful response every time using fetch-mock fetchMock.get(baseURL + '/endpoint', { status: 200, @@ -265,7 +223,14 @@ describe('Request Handler', () => { mockDelayInvocation.mockResolvedValue(true); // Make the request - await requestHandler.request('/endpoint'); + await fetchf(baseURL + '/endpoint', { + baseURL, + retry: { + retries: 0, // No retries for this test + }, + ...pollingConfig, + logger: mockLogger, + }); // Advance timers to cover the polling interval jest.advanceTimersByTime(300); // pollingInterval * 3 @@ -283,8 +248,12 @@ describe('Request Handler', () => { }); it('should not poll if pollingInterval is not provided', async () => { - // Setup without polling configuration - const requestHandler = createRequestHandler({ + fetchMock.getOnce(baseURL + '/endpoint', { + status: 200, + body: {}, + }); + + await fetchf(baseURL + '/endpoint', { baseURL, retry: { retries: 0, // No retries for this test @@ -293,13 +262,6 @@ describe('Request Handler', () => { logger: mockLogger, }); - fetchMock.getOnce(baseURL + '/endpoint', { - status: 200, - body: {}, - }); - - await requestHandler.request('/endpoint'); - // Ensure fetch was only called once expect(fetchMock.callHistory.calls(baseURL + '/endpoint').length).toBe(1); @@ -308,17 +270,6 @@ describe('Request Handler', () => { }); it('should stop polling on error and not proceed with polling attempts', async () => { - const requestHandler = createRequestHandler({ - baseURL, - retry: { - retries: 0, // No retries for this test - }, - pollingInterval: 100, - shouldStopPolling: jest.fn(() => false), // Always continue polling if no errors - logger: mockLogger, - }); - - // Mock fetch to fail using fetch-mock fetchMock.getOnce(baseURL + '/endpoint', { status: 500, body: 'fail', @@ -331,7 +282,15 @@ describe('Request Handler', () => { mockDelayInvocation.mockResolvedValue(true); await expect( - requestHandler.request(baseURL + '/endpoint'), + fetchf(baseURL + '/endpoint', { + baseURL, + retry: { + retries: 0, // No retries for this test + }, + pollingInterval: 100, + shouldStopPolling: jest.fn(() => false), // Always continue polling if no errors + logger: mockLogger, + }), ).rejects.toMatchObject({ status: 500, }); @@ -347,27 +306,24 @@ describe('Request Handler', () => { }); it('should call delay invocation correct number of times', async () => { - const requestHandler = createRequestHandler({ + fetchMock.get(baseURL + '/endpoint', { + status: 200, + body: {}, + }); + + await fetchf(baseURL + '/endpoint', { baseURL, retry: { retries: 0, // No retries for this test }, pollingInterval: 100, - shouldStopPolling: jest.fn((_response, pollingAttempt) => { + shouldStopPolling: jest.fn((_response: unknown, pollingAttempt) => { // Stop polling after 3 attempts return pollingAttempt === 3; }), logger: mockLogger, }); - // Use fetch-mock to return a successful response - fetchMock.get(baseURL + '/endpoint', { - status: 200, - body: {}, - }); - - await requestHandler.request('/endpoint'); - // Advance timers to cover polling interval jest.advanceTimersByTime(300); // pollingInterval * 3 @@ -381,17 +337,15 @@ describe('Request Handler', () => { shouldStopPolling: jest.fn(() => true), // Stop immediately }; - const requestHandler = createRequestHandler({ + fetchMock.getOnce(baseURL + '/endpoint', { status: 200, body: {} }); + + await fetchf(baseURL + '/endpoint', { baseURL, retry: { retries: 0 }, ...pollingConfig, logger: mockLogger, }); - fetchMock.getOnce(baseURL + '/endpoint', { status: 200, body: {} }); - - await requestHandler.request('/endpoint'); - expect(fetchMock.callHistory.calls(baseURL + '/endpoint').length).toBe(1); expect(pollingConfig.shouldStopPolling).toHaveBeenCalledTimes(1); }); @@ -424,14 +378,6 @@ describe('Request Handler', () => { }), // Always retry }; - // Initialize RequestHandler with mock configuration - const requestHandler = createRequestHandler({ - baseURL, - retry: retryConfig, - logger: mockLogger, - onError: jest.fn(), - }); - // Mock fetch to fail twice and then succeed let callCount = 0; (globalThis.fetch as jest.Mock).mockImplementation(() => { @@ -456,7 +402,14 @@ describe('Request Handler', () => { mockDelayInvocation.mockResolvedValue(true); // Make the request - await expect(requestHandler.request('/endpoint')).resolves.not.toThrow(); + await expect( + fetchf(baseURL + '/endpoint', { + baseURL, + retry: retryConfig, + logger: mockLogger, + onError: jest.fn(), + }), + ).resolves.not.toThrow(); // Advance timers to cover the delay period const totalDelay = @@ -490,12 +443,6 @@ describe('Request Handler', () => { retryOn: [500], // Retry on server errors shouldRetry: jest.fn(() => Promise.resolve(true)), }; - const requestHandler = createRequestHandler({ - baseURL, - retry: retryConfig, - logger: mockLogger, - onError: jest.fn(), - }); (globalThis.fetch as jest.Mock).mockRejectedValue({ status: 500, @@ -507,9 +454,16 @@ describe('Request Handler', () => { >; mockDelayInvocation.mockResolvedValue(false); + const onRetry = jest.fn(); try { - await requestHandler.request('/endpoint'); + await fetchf(baseURL + '/endpoint', { + baseURL, + retry: retryConfig, + logger: mockLogger, + onError: jest.fn(), + onRetry, + }); // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (_e) { // @@ -520,15 +474,13 @@ describe('Request Handler', () => { expect(globalThis.fetch).toHaveBeenCalledTimes(retryConfig.retries + 1); // Check delay between retries - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Attempt 1 failed. Retry in 100ms.', - ); - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Attempt 2 failed. Retry in 150ms.', - ); - expect(mockLogger.warn).toHaveBeenCalledWith( - 'Attempt 3 failed. Retry in 225ms.', + expect(onRetry).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.anything(), + }), + expect.any(Number), ); + expect(onRetry).toHaveBeenCalledTimes(retryConfig.retries); }); it('should not retry if the error status is not in retryOn list', async () => { @@ -538,21 +490,51 @@ describe('Request Handler', () => { maxDelay: 5000, backoff: 1.5, retryOn: [500], - shouldRetry: jest.fn(() => Promise.resolve(true)), }; - const requestHandler = createRequestHandler({ - baseURL, - retry: retryConfig, - logger: mockLogger, - onError: jest.fn(), + + (globalThis.fetch as jest.Mock).mockRejectedValue({ + status: 400, + json: jest.fn().mockResolvedValue({}), }); + await expect( + fetchf(baseURL + '/endpoint', { + baseURL, + retry: retryConfig, + logger: mockLogger, + onError: jest.fn(), + }), + ).rejects.toMatchObject({ + status: 400, + json: expect.any(Function), + }); + + expect(globalThis.fetch).toHaveBeenCalledTimes(1); // No retries + }); + + it('should not retry if the error status is not in retryOn list and shouldRetry calls for status check', async () => { + const retryConfig = { + retries: 2, + delay: 100, + maxDelay: 5000, + backoff: 1.5, + retryOn: [500], + shouldRetry: jest.fn(() => Promise.resolve(null)), + }; + (globalThis.fetch as jest.Mock).mockRejectedValue({ status: 400, json: jest.fn().mockResolvedValue({}), }); - await expect(requestHandler.request('/endpoint')).rejects.toMatchObject({ + await expect( + fetchf(baseURL + '/endpoint', { + baseURL, + retry: retryConfig, + logger: mockLogger, + onError: jest.fn(), + }), + ).rejects.toMatchObject({ status: 400, json: expect.any(Function), }); @@ -569,12 +551,6 @@ describe('Request Handler', () => { retryOn: [500], shouldRetry: jest.fn(() => Promise.resolve(true)), }; - const requestHandler = createRequestHandler({ - baseURL, - retry: retryConfig, - logger: mockLogger, - onError: jest.fn(), - }); (globalThis.fetch as jest.Mock).mockRejectedValue({ status: 500, @@ -588,7 +564,12 @@ describe('Request Handler', () => { mockDelayInvocation.mockResolvedValue(false); try { - await requestHandler.request('/endpoint'); + await fetchf(baseURL + '/endpoint', { + baseURL, + retry: retryConfig, + logger: mockLogger, + onError: jest.fn(), + }); // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (_e) { // @@ -612,19 +593,20 @@ describe('Request Handler', () => { retryOn: [500], shouldRetry: jest.fn(() => Promise.resolve(false)), }; - const requestHandler = createRequestHandler({ - baseURL, - retry: retryConfig, - logger: mockLogger, - onError: jest.fn(), - }); (globalThis.fetch as jest.Mock).mockRejectedValue({ status: 500, json: jest.fn().mockResolvedValue({}), }); - await expect(requestHandler.request('/endpoint')).rejects.toMatchObject({ + await expect( + fetchf(baseURL + '/endpoint', { + baseURL, + retry: retryConfig, + logger: mockLogger, + onError: jest.fn(), + }), + ).rejects.toMatchObject({ status: 500, json: expect.any(Function), }); @@ -645,18 +627,6 @@ describe('Request Handler', () => { }), }; - // Initialize RequestHandler with mock configuration - const requestHandler = createRequestHandler({ - baseURL, - retry: retryConfig, - logger: mockLogger, - onError: jest.fn(), - onResponse: jest.fn(() => { - // Simulate throwing an error in onResponse - throw new Error('Simulated error in onResponse'); - }), - }); - const fm = fetchMock.createInstance(); fm.mockGlobal(); @@ -677,9 +647,18 @@ describe('Request Handler', () => { mockDelayInvocation.mockResolvedValue(true); // Make the request - await expect(requestHandler.request('/endpoint')).rejects.toThrow( - 'Simulated error in onResponse', - ); + await expect( + fetchf(baseURL + '/endpoint', { + baseURL, + retry: retryConfig, + logger: mockLogger, + onError: jest.fn(), + onResponse: jest.fn(() => { + // Simulate throwing an error in onResponse + throw new Error('Simulated error in onResponse'); + }), + }), + ).rejects.toThrow('Simulated error in onResponse'); // Advance timers to cover the delay period const totalDelay = @@ -726,14 +705,6 @@ describe('Request Handler', () => { }), }; - // Initialize RequestHandler with mock configuration - const requestHandler = createRequestHandler({ - baseURL, - retry: retryConfig, - logger: mockLogger, - onError: jest.fn(), - }); - // Mock fetch to return a response with bookId: 'none' for retries let callCount = 0; (globalThis.fetch as jest.Mock).mockImplementation(() => { @@ -762,7 +733,12 @@ describe('Request Handler', () => { mockDelayInvocation.mockResolvedValue(true); // Make the request - const response = await requestHandler.request('/endpoint'); + const response = await fetchf(baseURL + '/endpoint', { + baseURL, + retry: retryConfig, + logger: mockLogger, + onError: jest.fn(), + }); // Advance timers to cover the delay period const totalDelay = @@ -1098,22 +1074,11 @@ describe('Request Handler', () => { }); describe('request() with interceptors', () => { - let requestHandler: RequestHandlerReturnType; - const spy = jest.spyOn(interceptorManager, 'applyInterceptor'); + const spy = jest.spyOn(interceptorManager, 'applyInterceptors'); jest.useFakeTimers(); beforeEach(() => { - requestHandler = createRequestHandler({ - baseURL: 'https://api.example.com', - timeout: 5000, - cancellable: true, - rejectCancelled: true, - strategy: 'reject', - defaultResponse: null, - onError: () => {}, - }); - fetchMock.clearHistory(); fetchMock.removeRoutes(); spy.mockClear(); @@ -1125,28 +1090,24 @@ describe('Request Handler', () => { }); it('should propagate error thrown by onRequest interceptor', async () => { - const handler = createRequestHandler({ - onRequest: () => { - throw new Error('Interceptor error'); - }, - }); await expect( - handler.request('http://example.com/api/err'), + fetchf('http://example.com/api/err', { + onRequest: () => { + throw new Error('Interceptor error'); + }, + }), ).rejects.toThrow('Interceptor error'); }); it('should call onError and onResponse hooks', async () => { const onError = jest.fn(); const onResponse = jest.fn(); - const handler = createRequestHandler({ - onError, - onResponse, - }); + fetchMock.getOnce('http://example.com/api/hook', { status: 200, body: { foo: 'bar' }, }); - await handler.request('http://example.com/api/hook'); + await fetchf('http://example.com/api/hook', { onError, onResponse }); expect(onResponse).toHaveBeenCalled(); fetchMock.getOnce('http://example.com/api/hook-fail', { @@ -1155,7 +1116,7 @@ describe('Request Handler', () => { }); await expect( - handler.request('http://example.com/api/hook-fail'), + fetchf('http://example.com/api/hook-fail', { onError, onResponse }), ).rejects.toThrow(); expect(onError).toHaveBeenCalled(); }); @@ -1168,10 +1129,37 @@ describe('Request Handler', () => { const url = '/test-endpoint'; const params = { key: 'value' }; + const onRequestFn = jest.fn(); + const onResponseFn = jest.fn(); + const onErrorFn = jest.fn(); + + setDefaultConfig({ + onRequest: onRequestFn, + onResponse: onResponseFn, + }); + + await fetchf(url, { + baseURL: 'https://api.example.com', + timeout: 5000, + cancellable: true, + rejectCancelled: true, + strategy: 'reject', + defaultResponse: null, + onError: onErrorFn, + onRequest: onRequestFn, + onResponse: onResponseFn, + params, + }); - await requestHandler.request(url, { params }); + expect(onRequestFn).toHaveBeenCalledTimes(2); + expect(onResponseFn).toHaveBeenCalledTimes(2); + expect(onErrorFn).toHaveBeenCalledTimes(0); + expect(spy).toHaveBeenCalledTimes(2); - expect(spy).toHaveBeenCalledTimes(4); + setDefaultConfig({ + onRequest: undefined, + onResponse: undefined, + }); }); it('should handle modified config in interceptRequest', async () => { @@ -1190,9 +1178,18 @@ describe('Request Handler', () => { }, } as RequestConfig; - await requestHandler.request(url, { ...config, params }); + await fetchf(url, { + baseURL: 'https://api.example.com', + timeout: 5000, + cancellable: true, + rejectCancelled: true, + strategy: 'reject', + defaultResponse: null, + ...config, + params, + }); - expect(spy).toHaveBeenCalledTimes(4); + expect(spy).toHaveBeenCalledTimes(1); const lastCall = fetchMock.callHistory.lastCall(); expect(lastCall?.options?.headers).toMatchObject({ @@ -1200,7 +1197,7 @@ describe('Request Handler', () => { }); }); - it('should handle modified response in applyInterceptor', async () => { + it('should handle modified response in applyInterceptors', async () => { const modifiedUrl = 'https://api.example.com/test-endpoint?key=value'; fetchMock.route( @@ -1218,17 +1215,23 @@ describe('Request Handler', () => { }, }; - const { data, config } = await requestHandler.request(url, { + const { data, config } = await fetchf(url, { + baseURL: 'https://api.example.com', + timeout: 5000, + cancellable: true, + rejectCancelled: true, + strategy: 'reject', + defaultResponse: null, ...requestConfig, params, }); - expect(spy).toHaveBeenCalledTimes(4); + expect(spy).toHaveBeenCalledTimes(1); expect(data).toMatchObject({ username: 'modified response' }); expect(config.url).toContain(modifiedUrl); }); - it('should handle request failure with interceptors', async () => { + it('should handle request failure without calling interceptors', async () => { fetchMock.route('https://api.example.com/test-endpoint?key=value', { status: 500, body: { error: 'Server error' }, @@ -1236,16 +1239,22 @@ describe('Request Handler', () => { const url = '/test-endpoint'; const params = { key: 'value' }; - const config = {}; await expect( - requestHandler.request(url, { ...config, params }), + fetchf(url, { + baseURL: 'https://api.example.com', + timeout: 5000, + cancellable: true, + rejectCancelled: true, + strategy: 'reject', + defaultResponse: null, + params, + }), ).rejects.toThrow( 'https://api.example.com/test-endpoint?key=value failed! Status: 500', ); - // Only request and error interceptors are called (4 because 2 for request and 2 for errors) - expect(spy).toHaveBeenCalledTimes(4); + expect(spy).toHaveBeenCalledTimes(0); }); it('should handle request with different response status', async () => { @@ -1259,13 +1268,21 @@ describe('Request Handler', () => { const config = {}; await expect( - requestHandler.request(url, { ...config, params }), + fetchf(url, { + baseURL: 'https://api.example.com', + timeout: 5000, + cancellable: true, + rejectCancelled: true, + strategy: 'reject', + defaultResponse: null, + ...config, + params, + }), ).rejects.toThrow( 'https://api.example.com/test-endpoint?key=value failed! Status: 404', ); - // Only request and error interceptors are called (4 because 2 for request and 2 for errors) - expect(spy).toHaveBeenCalledTimes(4); + expect(spy).toHaveBeenCalledTimes(0); }); }); @@ -1280,15 +1297,11 @@ describe('Request Handler', () => { }); it('should properly hang promise when using Silent strategy', async () => { - const requestHandler = createRequestHandler({ - strategy: 'silent', - }); - globalThis.fetch = jest .fn() .mockRejectedValue(new Error('Request Failed')); - const request = requestHandler.request(apiUrl); + const request = fetchf(apiUrl, { strategy: 'silent' }); const timeout = new Promise((resolve) => { const wait = setTimeout(() => { @@ -1308,16 +1321,14 @@ describe('Request Handler', () => { }); it('should reject promise when using rejection strategy', async () => { - const requestHandler = createRequestHandler({ - strategy: 'reject', - }); - - globalThis.fetch = jest - .fn() - .mockRejectedValue(new Error('Request Failed')); + fetchMock.getOnce(apiUrl, 500); try { - const response = await (requestHandler as any).delete(apiUrl); + const response = await fetchf(apiUrl, { + fetcher, + strategy: 'reject', + method: 'DELETE', + }); expect(response).toBe(undefined); } catch (error) { expect(typeof error).toBe('object'); @@ -1325,18 +1336,10 @@ describe('Request Handler', () => { }); it('should reject promise when using reject strategy per endpoint', async () => { - const requestHandler = createRequestHandler({ - strategy: 'silent', - }); - - globalThis.fetch = jest - .fn() - .mockRejectedValue(new Error('Request Failed')); + fetchMock.getOnce(apiUrl, 500); try { - await requestHandler.request(apiUrl, { - strategy: 'reject', - }); + await fetchf(apiUrl, { strategy: 'reject' }); } catch (error) { expect(typeof error).toBe('object'); } @@ -1394,12 +1397,6 @@ describe('Request Handler', () => { }); it('should cancel previous request and pass a different successive request', async () => { - const requestHandler = createRequestHandler({ - cancellable: true, - rejectCancelled: true, - flattenResponse: true, - }); - fetchMock.route( 'https://example.com/first', new Promise((_resolve, reject) => { @@ -1412,10 +1409,16 @@ describe('Request Handler', () => { body: { username: 'response from second request' }, }); - const firstRequest = requestHandler.request('https://example.com/first'); - const secondRequest = requestHandler.request( - 'https://example.com/second', - ); + const firstRequest = fetchf('https://example.com/first', { + cancellable: true, + rejectCancelled: true, + flattenResponse: true, + }); + const secondRequest = fetchf('https://example.com/second', { + cancellable: true, + rejectCancelled: true, + flattenResponse: true, + }); expect(secondRequest).resolves.toMatchObject({ data: { username: 'response from second request' }, @@ -1424,12 +1427,6 @@ describe('Request Handler', () => { }); it('should not cancel previous request when cancellable is set to false', async () => { - const requestHandler = createRequestHandler({ - cancellable: false, // No request cancellation - rejectCancelled: true, - flattenResponse: false, - }); - // Mock the first request fetchMock.route('https://example.com/first', { status: 200, @@ -1442,10 +1439,16 @@ describe('Request Handler', () => { body: { data: { message: 'response from second request' } }, }); - const firstRequest = requestHandler.request('https://example.com/first'); - const secondRequest = requestHandler.request( - 'https://example.com/second', - ); + const firstRequest = fetchf('https://example.com/first', { + cancellable: false, // No request cancellation + rejectCancelled: true, + flattenResponse: false, + }); + const secondRequest = fetchf('https://example.com/second', { + cancellable: false, // No request cancellation + rejectCancelled: true, + flattenResponse: false, + }); // Validate both requests resolve successfully without any cancellation await expect(firstRequest).resolves.toMatchObject({ @@ -1503,44 +1506,37 @@ describe('Request Handler', () => { }); it('should return defaultResponse if response is empty', async () => { - const handler = createRequestHandler({ defaultResponse: { foo: 'bar' } }); fetchMock.getOnce('http://example.com/api/empty', { status: 200, body: {}, }); - const result = await handler.request('http://example.com/api/empty'); + const result = await fetchf('http://example.com/api/empty', { + defaultResponse: { foo: 'bar' }, + }); expect(result.data).toEqual({ foo: 'bar' }); }); it('should show nested data object if flattening is off', async () => { - const requestHandler = createRequestHandler({ + fetcher = jest.fn().mockResolvedValue({ data: responseMock, ok: true }); + + const { data } = await fetchf(apiUrl, { fetcher, flattenResponse: false, + method: 'PUT', }); - (requestHandler.getInstance() as any).request = jest - .fn() - .mockResolvedValue(responseMock); - - const response = await requestHandler.request(apiUrl, { - method: 'put', - }); - - expect(response).toMatchObject(responseMock); + expect(data).toMatchObject({ data: responseMock }); }); it('should handle deeply nested data if data flattening is on', async () => { - const requestHandler = createRequestHandler({ - fetcher, - flattenResponse: true, - }); - - (requestHandler.getInstance() as any).request = jest + fetcher = jest .fn() - .mockResolvedValue({ data: responseMock }); + .mockResolvedValue({ data: { data: responseMock }, ok: true }); - const { data } = await requestHandler.request(apiUrl, { - method: 'patch', + const { data } = await fetchf(apiUrl, { + fetcher, + flattenResponse: true, + method: 'PATCH', }); expect(data).toMatchObject(responseMock.data); @@ -1548,44 +1544,37 @@ describe('Request Handler', () => { }); it('should return null if there is no data', async () => { - const requestHandler = createRequestHandler({ - fetcher, - flattenResponse: true, - defaultResponse: null, - }); - - (requestHandler.getInstance() as any).request = jest - .fn() - .mockResolvedValue({ data: null }); + fetcher = jest.fn().mockResolvedValue({ data: null, ok: true }); expect( - await requestHandler.request(apiUrl, { method: 'head' }), - ).toMatchObject({ data: null }); + await fetchf(apiUrl, { + fetcher, + flattenResponse: true, + defaultResponse: null, + method: 'HEAD', + }), + ).toMatchObject({ + data: null, + }); }); }); describe('request() cache', () => { const apiUrl = 'http://example.com/api/cache-test'; - let requestHandler: RequestHandlerReturnType; beforeEach(() => { jest.useFakeTimers(); fetchMock.clearHistory(); fetchMock.removeRoutes(); fetchMock.mockGlobal(); - requestHandler = createRequestHandler({ - cacheTime: 60, - }); }); afterEach(() => { fetchMock.clearHistory(); fetchMock.removeRoutes(); - + pruneCache(); // Advance time to ensure cache expiration jest.advanceTimersByTime(61000); // 61 seconds > cacheTime of 60 seconds - - pruneCache(0.0000001); jest.useRealTimers(); }); @@ -1597,26 +1586,37 @@ describe('Request Handler', () => { }); // First request - should hit the network - const firstResponse = await requestHandler.request(apiUrl); + const firstResponse = await fetchf(apiUrl, { cacheTime: 60 }); expect(firstResponse.data).toEqual({ value: 'cached' }); expect(callCount).toBe(1); // Second request - should return cached data, not hit the network - const secondResponse = await requestHandler.request(apiUrl); + const secondResponse = await fetchf(apiUrl, { cacheTime: 60 }); expect(secondResponse.data).toEqual({ value: 'cached' }); expect(callCount).toBe(1); }); - it('should bypass cache if cacheTime is 0', async () => { + it('should bypass cache if cacheTime is undefined', async () => { + let callCount = 0; + fetchMock.get(apiUrl, () => { + callCount++; + return { status: 200, body: { value: 'no-cache' } }; + }); + + await fetchf(apiUrl, { cacheTime: undefined }); + await fetchf(apiUrl, { cacheTime: undefined }); + expect(callCount).toBe(2); + }); + + it('should not bypass cache if cacheTime is 0', async () => { let callCount = 0; fetchMock.get(apiUrl, () => { callCount++; return { status: 200, body: { value: 'no-cache' } }; }); - const handlerNoCache = createRequestHandler({ cacheTime: 0 }); - await handlerNoCache.request(apiUrl); - await handlerNoCache.request(apiUrl); + await fetchf(apiUrl, { cacheTime: 0 }); + await fetchf(apiUrl, { cacheTime: 0 }); expect(callCount).toBe(2); }); @@ -1632,11 +1632,18 @@ describe('Request Handler', () => { return { status: 200, body: { value: 'B' } }; }); - const handler = createRequestHandler({ cacheTime: 60 }); - const respA1 = await handler.request('http://example.com/api/a'); - const respA2 = await handler.request('http://example.com/api/a'); - const respB1 = await handler.request('http://example.com/api/b'); - const respB2 = await handler.request('http://example.com/api/b'); + const respA1 = await fetchf('http://example.com/api/a', { + cacheTime: 60, + }); + const respA2 = await fetchf('http://example.com/api/a', { + cacheTime: 60, + }); + const respB1 = await fetchf('http://example.com/api/b', { + cacheTime: 60, + }); + const respB2 = await fetchf('http://example.com/api/b', { + cacheTime: 60, + }); expect(respA1.data).toEqual({ value: 'A' }); expect(respA2.data).toEqual({ value: 'A' }); expect(respB1.data).toEqual({ value: 'B' }); @@ -1652,13 +1659,12 @@ describe('Request Handler', () => { return { status: 200, body: { value: 'expire' } }; }); // Use 1 second for cacheTime to avoid timing issues - const handler = createRequestHandler({ cacheTime: 1 }); - const resp1 = await handler.request(apiUrl); + const resp1 = await fetchf(apiUrl, { cacheTime: 1 }); expect(resp1.data).toEqual({ value: 'expire' }); // Simulate cache expiration (advance by 1100ms > 1s) jest.advanceTimersByTime(1100); - const resp2 = await handler.request(apiUrl, { - // Skip setting cache in the 2nd request + const resp2 = await fetchf(apiUrl, { + cacheTime: 1, skipCache: () => true, }); expect(resp2.data).toEqual({ value: 'expire' }); @@ -1672,26 +1678,28 @@ describe('Request Handler', () => { return { status: 200, body: { value: 'skip' } }; }); - const handler = createRequestHandler({ - cacheTime: 60, - }); - // Provide skipCache that always returns true - const resp1 = await handler.request(apiUrl, { + const resp1 = await fetchf(apiUrl, { + cacheTime: 60, skipCache: () => true, }); expect(resp1.data).toEqual({ value: 'skip' }); + expect(resp1.config).toBeDefined(); expect(callCount).toBe(1); + jest.advanceTimersByTime(1000 * 61); // Advance time to ensure cache expiration + // Second request should hit the network again (no cache set) - const resp2 = await handler.request(apiUrl, { - skipCache: () => false, // now allow caching + const resp2 = await fetchf(apiUrl, { + cacheTime: 60, + skipCache: () => false, }); expect(resp2.data).toEqual({ value: 'skip' }); expect(callCount).toBe(2); // Third request should return cached data (cache was set on previous call) - const resp3 = await handler.request(apiUrl, { + const resp3 = await fetchf(apiUrl, { + cacheTime: 60, skipCache: () => false, }); expect(resp3.data).toEqual({ value: 'skip' }); @@ -1705,26 +1713,25 @@ describe('Request Handler', () => { return { status: 200, body: { value: 'cache' } }; }); - const handler = createRequestHandler({ - cacheTime: 60, - }); - // Provide skipCache that always returns false - const resp1 = await handler.request(apiUrl, { + const resp1 = await fetchf(apiUrl, { + cacheTime: 60, skipCache: () => false, }); expect(resp1.data).toEqual({ value: 'cache' }); expect(callCount).toBe(1); // Second request should return cached data - const resp2 = await handler.request(apiUrl, { + const resp2 = await fetchf(apiUrl, { + cacheTime: 60, skipCache: () => false, }); expect(resp2.data).toEqual({ value: 'cache' }); expect(callCount).toBe(1); // Third request should return cached data - const resp3 = await handler.request(apiUrl, { + const resp3 = await fetchf(apiUrl, { + cacheTime: 60, skipCache: () => false, }); expect(resp3.data).toEqual({ value: 'cache' }); @@ -1738,15 +1745,11 @@ describe('Request Handler', () => { return { status: 200, body: { value: 'default' } }; }); - const handler = createRequestHandler({ - cacheTime: 60, - }); - - const resp1 = await handler.request(apiUrl); + const resp1 = await fetchf(apiUrl, { cacheTime: 60 }); expect(resp1.data).toEqual({ value: 'default' }); expect(callCount).toBe(1); - const resp2 = await handler.request(apiUrl); + const resp2 = await fetchf(apiUrl, { cacheTime: 60 }); expect(resp2.data).toEqual({ value: 'default' }); expect(callCount).toBe(1); }); @@ -1759,18 +1762,20 @@ describe('Request Handler', () => { }); const customKey = 'my-custom-key'; - const handler = createRequestHandler({ + + // First request - should hit the network + const resp1 = await fetchf(apiUrl, { cacheTime: 60, cacheKey: () => customKey, }); - - // First request - should hit the network - const resp1 = await handler.request(apiUrl); expect(resp1.data).toEqual({ value: 'custom-key' }); expect(callCount).toBe(1); // Second request - should return cached data using custom key - const resp2 = await handler.request(apiUrl); + const resp2 = await fetchf(apiUrl, { + cacheTime: 60, + cacheKey: () => customKey, + }); expect(resp2.data).toEqual({ value: 'custom-key' }); expect(callCount).toBe(1); }); @@ -1784,23 +1789,28 @@ describe('Request Handler', () => { return { status: 200, body: { value: 'custom-key-multi' } }; }, ); - const handler = createRequestHandler({ + + // First request with one key + const resp1 = await fetchf(apiUrl, { cacheTime: 60, cacheKey: (cfg) => cfg.url + '-custom', }); - - // First request with one key - const resp1 = await handler.request(apiUrl); expect(resp1.data).toEqual({ value: 'custom-key-multi' }); expect(callCount).toBe(1); // Second request with a different key (simulate different url) - const resp2 = await handler.request(apiUrl + '?v=2'); + const resp2 = await fetchf(apiUrl + '?v=2', { + cacheTime: 60, + cacheKey: (cfg) => cfg.url + '-custom', + }); expect(resp2.data).toEqual({ value: 'custom-key-multi' }); expect(callCount).toBe(2); // Third request with first key again (should be cached) - const resp3 = await handler.request(apiUrl); + const resp3 = await fetchf(apiUrl, { + cacheTime: 60, + cacheKey: (cfg) => cfg.url + '-custom', + }); expect(resp3.data).toEqual({ value: 'custom-key-multi' }); expect(callCount).toBe(2); }); diff --git a/test/revalidator-manager.spec.ts b/test/revalidator-manager.spec.ts new file mode 100644 index 00000000..e13024bf --- /dev/null +++ b/test/revalidator-manager.spec.ts @@ -0,0 +1,1260 @@ +/** + * @jest-environment jsdom + */ +import { + addRevalidator, + removeRevalidator, + revalidate, + startRevalidatorCleanup, + removeRevalidators, +} from '../src/revalidator-manager'; + +describe('Revalidator Manager', () => { + let mockRevalidatorFn: jest.Mock; + let mockRevalidatorFn2: jest.Mock; + const testKey = 'test-key'; + const testKey2 = 'test-key-2'; + + beforeEach(() => { + mockRevalidatorFn = jest.fn().mockResolvedValue(undefined); + mockRevalidatorFn2 = jest.fn().mockResolvedValue(undefined); + jest.clearAllMocks(); + }); + + afterEach(() => { + // Clean up registered revalidators + removeRevalidator(testKey); + removeRevalidator(testKey2); + jest.clearAllTimers(); + }); + + describe('addRevalidator', () => { + it('should register a revalidator function for a key', async () => { + addRevalidator(testKey, mockRevalidatorFn); + + await revalidate(testKey); + + expect(mockRevalidatorFn).toHaveBeenCalledTimes(1); + }); + + it('should overwrite existing revalidator for the same key', async () => { + const firstFn = jest.fn().mockResolvedValue(undefined); + const secondFn = jest.fn().mockResolvedValue(undefined); + + addRevalidator(testKey, firstFn); + addRevalidator(testKey, secondFn); + + await revalidate(testKey); + + expect(firstFn).not.toHaveBeenCalled(); + expect(secondFn).toHaveBeenCalledTimes(1); + }); + + it('should allow multiple keys with different revalidators', async () => { + addRevalidator(testKey, mockRevalidatorFn); + addRevalidator(testKey2, mockRevalidatorFn2); + + await revalidate(testKey); + await revalidate(testKey2); + + expect(mockRevalidatorFn).toHaveBeenCalledTimes(1); + expect(mockRevalidatorFn2).toHaveBeenCalledTimes(1); + }); + }); + + describe('removeRevalidator', () => { + it('should remove a registered revalidator', async () => { + addRevalidator(testKey, mockRevalidatorFn); + removeRevalidator(testKey); + + const result = await revalidate(testKey); + + expect(mockRevalidatorFn).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); + + it('should disable focus revalidation when unregistering', () => { + addRevalidator( + testKey, + mockRevalidatorFn, + undefined, + undefined, + undefined, + true, + ); + + // This should not throw and should clean up focus revalidation + expect(() => removeRevalidator(testKey)).not.toThrow(); + }); + + it('should handle unregistering non-existent key gracefully', () => { + expect(() => removeRevalidator('non-existent-key')).not.toThrow(); + }); + }); + + describe('revalidate', () => { + it('should execute registered revalidator function', async () => { + addRevalidator(testKey, mockRevalidatorFn); + + const result = await revalidate(testKey); + + expect(mockRevalidatorFn).toHaveBeenCalledTimes(1); + expect(result).toBeUndefined(); + }); + + it('should return null for non-existent key', async () => { + const result = await revalidate('non-existent-key'); + + expect(result).toBeNull(); + }); + + it('should propagate errors from revalidator function', async () => { + const errorFn = jest + .fn() + .mockRejectedValue(new Error('Revalidation failed')); + addRevalidator(testKey, errorFn); + + await expect(revalidate(testKey)).rejects.toThrow('Revalidation failed'); + expect(errorFn).toHaveBeenCalledTimes(1); + }); + + it('should handle revalidator function that returns a value', async () => { + const returnValueFn = jest.fn().mockResolvedValue('some-value'); + addRevalidator(testKey, returnValueFn); + + const result = await revalidate(testKey); + + expect(returnValueFn).toHaveBeenCalledTimes(1); + expect(result).toBe('some-value'); + }); + + it('should handle empty key gracefully', async () => { + const result = await revalidate(''); + + expect(result).toBeNull(); + }); + }); + + describe('addRevalidator with focus revalidation', () => { + it('should add key to focus revalidation set', () => { + expect(() => + addRevalidator( + testKey, + mockRevalidatorFn, + undefined, + undefined, + undefined, + true, + ), + ).not.toThrow(); + }); + + it('should handle adding the same key multiple times', () => { + addRevalidator( + testKey, + mockRevalidatorFn, + undefined, + undefined, + undefined, + true, + ); + addRevalidator( + testKey, + mockRevalidatorFn, + undefined, + undefined, + undefined, + true, + ); + + expect(() => + addRevalidator( + testKey, + mockRevalidatorFn, + undefined, + undefined, + undefined, + true, + ), + ).not.toThrow(); + }); + + it('should handle empty key', () => { + expect(() => + addRevalidator( + '', + mockRevalidatorFn, + undefined, + undefined, + undefined, + true, + ), + ).not.toThrow(); + }); + }); + + describe('focus revalidation behavior', () => { + let mockAddEventListener: jest.Mock; + + beforeEach(() => { + removeRevalidators('focus'); + mockAddEventListener = jest.fn(); + window.addEventListener = mockAddEventListener; + window.removeEventListener = jest.fn(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should test focus revalidation by directly calling the internal function', async () => { + // Since the module initialization is complex to test due to timing, + // we'll test the focus functionality through the public API + addRevalidator(testKey, mockRevalidatorFn); + addRevalidator(testKey2, mockRevalidatorFn2); + addRevalidator( + testKey, + mockRevalidatorFn, + undefined, + undefined, + undefined, + true, + ); + addRevalidator( + testKey2, + mockRevalidatorFn2, + undefined, + undefined, + undefined, + true, + ); + + // We can't easily test the actual focus event due to module initialization, + // but we can test that the functions work correctly when called directly + await revalidate(testKey); + await revalidate(testKey2); + + expect(mockRevalidatorFn).toHaveBeenCalled(); + expect(mockRevalidatorFn2).toHaveBeenCalled(); + }); + }); + + describe('error handling in focus revalidation', () => { + it('should handle errors in revalidators gracefully', async () => { + const errorFn = jest + .fn() + .mockRejectedValue(new Error('Focus revalidation failed')); + addRevalidator(testKey, errorFn); + + // Test that errors are propagated when calling revalidate directly + await expect(revalidate(testKey)).rejects.toThrow( + 'Focus revalidation failed', + ); + expect(errorFn).toHaveBeenCalled(); + }); + }); + + describe('TTL handling and cleanup', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should register revalidator with default TTL', async () => { + addRevalidator(testKey, mockRevalidatorFn); + + await revalidate(testKey); + expect(mockRevalidatorFn).toHaveBeenCalledTimes(1); + }); + + it('should register revalidator with custom TTL', async () => { + const customTTL = 5000; // 5 seconds + addRevalidator(testKey, mockRevalidatorFn, customTTL); + + await revalidate(testKey); + expect(mockRevalidatorFn).toHaveBeenCalledTimes(1); + }); + + it('should never expire revalidator with TTL = 0', async () => { + addRevalidator(testKey, mockRevalidatorFn, 0); + + // Fast forward time way beyond normal TTL + jest.advanceTimersByTime(60 * 60 * 1000); // 1 hour (reduced from 24) + + await revalidate(testKey); + expect(mockRevalidatorFn).toHaveBeenCalledTimes(1); + }); + + it('should update lastUsed timestamp on revalidation', async () => { + addRevalidator(testKey, mockRevalidatorFn); + + await revalidate(testKey); + jest.advanceTimersByTime(1000); + await revalidate(testKey); + + expect(mockRevalidatorFn).toHaveBeenCalledTimes(2); + }); + }); + + describe('async handling and fire-and-forget', () => { + let consoleSpy: jest.SpyInstance; + + beforeEach(() => { + consoleSpy = jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + }); + + it('should handle sync revalidator functions', async () => { + const syncFn = jest.fn().mockReturnValue('sync-result'); + addRevalidator(testKey, syncFn); + + const result = await revalidate(testKey); + expect(syncFn).toHaveBeenCalledTimes(1); + expect(result).toBe('sync-result'); + }); + + it('should handle async revalidator functions', async () => { + const asyncFn = jest.fn().mockResolvedValue('async-result'); + addRevalidator(testKey, asyncFn); + + const result = await revalidate(testKey); + expect(asyncFn).toHaveBeenCalledTimes(1); + expect(result).toBe('async-result'); + }); + + it('should handle revalidator that returns undefined', async () => { + const undefinedFn = jest.fn().mockResolvedValue(undefined); + addRevalidator(testKey, undefinedFn); + + const result = await revalidate(testKey); + expect(undefinedFn).toHaveBeenCalledTimes(1); + expect(result).toBeUndefined(); + }); + + it('should handle revalidator that returns null', async () => { + const nullFn = jest.fn().mockResolvedValue(null); + addRevalidator(testKey, nullFn); + + const result = await revalidate(testKey); + expect(nullFn).toHaveBeenCalledTimes(1); + expect(result).toBeNull(); + }); + }); + + describe('focus event simulation and cleanup', () => { + let mockAddEventListener: jest.Mock; + let focusHandler: (() => void) | undefined; + + beforeEach(() => { + jest.useFakeTimers(); + removeRevalidators('focus'); + + mockAddEventListener = jest.fn().mockImplementation((event, handler) => { + if (event === 'focus') { + focusHandler = handler; + } + }); + + window.addEventListener = mockAddEventListener; + window.removeEventListener = jest.fn(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('should call focus revalidators when focus event is triggered', () => { + addRevalidator( + testKey, + mockRevalidatorFn, + undefined, + undefined, + mockRevalidatorFn, + true, + ); + addRevalidator( + testKey2, + mockRevalidatorFn2, + undefined, + undefined, + mockRevalidatorFn2, + true, + ); + + // Simulate focus event + if (focusHandler) { + focusHandler(); + } + + // Since we're using fake timers, the Promise.resolve in the focus handler + // should resolve immediately, so we can check right away + expect(mockRevalidatorFn).toHaveBeenCalledTimes(1); + expect(mockRevalidatorFn2).toHaveBeenCalledTimes(1); + }); + + it('should not call manual revalidators on focus event', () => { + addRevalidator(testKey, mockRevalidatorFn); + addRevalidator(testKey2, mockRevalidatorFn2); + + // Simulate focus event + if (focusHandler) { + focusHandler(); + } + + // Manual revalidators should not be called + expect(mockRevalidatorFn).not.toHaveBeenCalled(); + expect(mockRevalidatorFn2).not.toHaveBeenCalled(); + }); + + it('should not clean up focus revalidators with TTL = 0', () => { + addRevalidator( + testKey, + mockRevalidatorFn, + 0, + undefined, + mockRevalidatorFn, + true, + ); + + // Fast forward way past normal TTL + jest.advanceTimersByTime(60 * 60 * 1000); // 1 hour (reduced from 24) + + // Simulate focus event + if (focusHandler) { + focusHandler(); + } + + // With fake timers, promises should resolve immediately + expect(mockRevalidatorFn).toHaveBeenCalledTimes(1); + }); + + it('should handle errors in focus revalidators without affecting others', () => { + const errorFn = jest.fn().mockRejectedValue(new Error('Focus error')); + const workingFn = jest.fn().mockResolvedValue('success'); + + addRevalidator(testKey, errorFn, undefined, undefined, errorFn, true); + addRevalidator( + testKey2, + workingFn, + undefined, + undefined, + workingFn, + true, + ); + + // Simulate focus event + if (focusHandler) { + focusHandler(); + } + + // With fake timers, the revalidators should be called immediately + expect(errorFn).toHaveBeenCalledTimes(1); + expect(workingFn).toHaveBeenCalledTimes(1); + }); + }); + + describe('edge cases and boundary conditions', () => { + it('should handle revalidate with empty string key', async () => { + const result = await revalidate(''); + expect(result).toBeNull(); + }); + + it('should handle revalidate with null key', async () => { + const result = await revalidate(null); + expect(result).toBeNull(); + }); + + it('should handle registering with empty string key', () => { + expect(() => addRevalidator('', mockRevalidatorFn)).not.toThrow(); + }); + + it('should handle unregistering with empty string key', () => { + expect(() => removeRevalidator('')).not.toThrow(); + }); + + it('should overwrite existing revalidator with same key', async () => { + const firstFn = jest.fn().mockResolvedValue('first'); + const secondFn = jest.fn().mockResolvedValue('second'); + + addRevalidator(testKey, firstFn); + addRevalidator(testKey, secondFn); + + const result = await revalidate(testKey); + expect(firstFn).not.toHaveBeenCalled(); + expect(secondFn).toHaveBeenCalledTimes(1); + expect(result).toBe('second'); + }); + + it('should handle mixed TTL values correctly', () => { + addRevalidator(testKey, mockRevalidatorFn, 1000); + addRevalidator(testKey2, mockRevalidatorFn2, 0); + + expect(() => revalidate(testKey)).not.toThrow(); + expect(() => revalidate(testKey2)).not.toThrow(); + }); + + it('should handle negative TTL as normal TTL', async () => { + addRevalidator(testKey, mockRevalidatorFn, -1000); + + const result = await revalidate(testKey); + expect(mockRevalidatorFn).toHaveBeenCalledTimes(1); + expect(result).toBeUndefined(); + }); + }); + + describe('removeRevalidator', () => { + it('should remove both manual and focus revalidators', async () => { + addRevalidator(testKey, mockRevalidatorFn); + addRevalidator( + testKey, + mockRevalidatorFn2, + undefined, + undefined, + undefined, + true, + ); + + removeRevalidator(testKey); + + // Neither should work after unregistering all + const result = await revalidate(testKey); + expect(result).toBeNull(); + expect(mockRevalidatorFn).not.toHaveBeenCalled(); + }); + + it('should handle unregistering when only manual exists', () => { + addRevalidator(testKey, mockRevalidatorFn); + + expect(() => removeRevalidator(testKey)).not.toThrow(); + }); + + it('should handle unregistering when only focus exists', () => { + addRevalidator( + testKey, + mockRevalidatorFn, + undefined, + undefined, + undefined, + true, + ); + + expect(() => removeRevalidator(testKey)).not.toThrow(); + }); + + it('should handle unregistering when neither exists', () => { + expect(() => removeRevalidator(testKey)).not.toThrow(); + }); + }); + + describe('unified tuple-based storage and comprehensive edge cases', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should store manual and focus revalidators in same map with different keys', async () => { + const manualFn = jest.fn().mockResolvedValue('manual'); + const focusFn = jest.fn().mockResolvedValue('focus'); + + addRevalidator(testKey, manualFn, 5000); + addRevalidator(testKey, focusFn, 10000, undefined, undefined, true); + + // Manual revalidation should only call manual function + const result = await revalidate(testKey); + expect(focusFn).toHaveBeenCalledTimes(1); + expect(manualFn).not.toHaveBeenCalled(); + expect(result).toBe('focus'); + + // Cleanup should work independently + removeRevalidator(testKey); + }); + + it('should handle revalidators with TTL = 0 never expiring', async () => { + addRevalidator(testKey, mockRevalidatorFn, 0); + addRevalidator( + testKey2, + mockRevalidatorFn2, + 0, + undefined, + undefined, + true, + ); + + const cleanup = startRevalidatorCleanup(50); // Reduced cleanup interval + + // Fast forward time (reduced from 24 hours) + jest.advanceTimersByTime(60 * 60 * 1000); // 1 hour + + // Both should still work + await revalidate(testKey); + expect(mockRevalidatorFn).toHaveBeenCalledTimes(1); + await revalidate(testKey2); + expect(mockRevalidatorFn2).toHaveBeenCalledTimes(1); + + cleanup(); + }); + + it('should handle periodic cleanup correctly', async () => { + addRevalidator(testKey, mockRevalidatorFn, 100); // Reduced TTL + + const cleanup = startRevalidatorCleanup(50); // Reduced cleanup interval + + // Initially should work + await revalidate(testKey); + expect(mockRevalidatorFn).toHaveBeenCalledTimes(1); + + // Fast forward past TTL and trigger cleanup + jest.advanceTimersByTime(200); // Reduced time advance + + // Should be cleaned up now + const result = await revalidate(testKey); + expect(result).toBeNull(); + + cleanup(); + }); + + it('should handle negative TTL values in cleanup', async () => { + addRevalidator(testKey, mockRevalidatorFn, -1000); + + const cleanup = startRevalidatorCleanup(50); // Reduced cleanup interval + jest.advanceTimersByTime(100); // Reduced time advance + + const result = await revalidate(testKey); + expect(result).toBeUndefined(); // Should be cleaned up + + cleanup(); + }); + + it('should handle extreme values gracefully', async () => { + addRevalidator(testKey, mockRevalidatorFn, Number.MAX_SAFE_INTEGER); + addRevalidator(testKey2, mockRevalidatorFn2, NaN); + + await revalidate(testKey); + await revalidate(testKey2); + + expect(mockRevalidatorFn).toHaveBeenCalledTimes(1); + expect(mockRevalidatorFn2).toHaveBeenCalledTimes(1); + }); + + it('should handle keys containing focus suffix correctly', async () => { + const trickeyKey = 'test|f|more'; + addRevalidator(trickeyKey, mockRevalidatorFn); + addRevalidator( + trickeyKey, + mockRevalidatorFn2, + undefined, + undefined, + undefined, + true, + ); + + await revalidate(trickeyKey); + expect(mockRevalidatorFn2).toHaveBeenCalledTimes(1); + + removeRevalidator(trickeyKey); + const result = await revalidate(trickeyKey); + expect(result).toBeNull(); + }); + + it('should handle self-modifying revalidators', async () => { + const selfModifyingFn = jest.fn().mockImplementation(() => { + removeRevalidator(testKey); + return 'self-modified'; + }); + + addRevalidator(testKey, selfModifyingFn); + + const result = await revalidate(testKey); + expect(result).toBe('self-modified'); + + // Second call should return null since it unregistered itself + const result2 = await revalidate(testKey); + expect(result2).toBeNull(); + }); + + it('should handle mixed return types from revalidators', async () => { + const multiTypeFn = jest + .fn() + .mockResolvedValueOnce('string') + .mockResolvedValueOnce(42) + .mockResolvedValueOnce({ data: 'object' }); + + addRevalidator(testKey, multiTypeFn); + + expect(await revalidate(testKey)).toBe('string'); + expect(await revalidate(testKey)).toBe(42); + expect(await revalidate(testKey)).toEqual({ data: 'object' }); + + expect(multiTypeFn).toHaveBeenCalledTimes(3); + }); + + it('should preserve TTL when updating lastUsed timestamp', async () => { + const customTTL = 10000; + addRevalidator(testKey, mockRevalidatorFn, customTTL); + + await revalidate(testKey); + jest.advanceTimersByTime(5000); + await revalidate(testKey); + + expect(mockRevalidatorFn).toHaveBeenCalledTimes(2); + }); + + it('should handle very large numbers of revalidators', async () => { + const promises = []; + // Reduced from 50 to 10 for faster execution + for (let i = 0; i < 10; i++) { + const fn = jest.fn().mockResolvedValue(`result-${i}`); + addRevalidator(`key-${i}`, fn, 50); // Reduced TTL + promises.push(revalidate(`key-${i}`)); + } + + await Promise.all(promises); + + const cleanup = startRevalidatorCleanup(25); // Reduced cleanup interval + jest.advanceTimersByTime(100); // Reduced time advance + + // Check fewer keys for speed + for (let i = 0; i < 5; i++) { + const result = await revalidate(`key-${i}`); + expect(result).toBeNull(); + } + + cleanup(); + }); + }); + + describe('focus revalidation with fire-and-forget async handling', () => { + let focusHandler: (() => void) | undefined; + + beforeEach(() => { + jest.useFakeTimers(); + removeRevalidators('focus'); + + const mockAddEventListener = jest + .fn() + .mockImplementation((event, handler) => { + if (event === 'focus') { + focusHandler = handler; + } + }); + + window.addEventListener = mockAddEventListener; + window.removeEventListener = jest.fn(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('should handle focus revalidation with proper cleanup timing and deletion before focus event', () => { + const shortTTL = 100; + addRevalidator( + testKey, + mockRevalidatorFn, + shortTTL, + undefined, + undefined, + true, + ); + + startRevalidatorCleanup(10); + + // Fast forward well past TTL + jest.advanceTimersByTime(shortTTL + 50); + + // Simulate deletion before focus event + removeRevalidator(testKey); + + // Focus event should not call the revalidator since it was deleted + if (focusHandler) { + focusHandler(); + } + + expect(mockRevalidatorFn).not.toHaveBeenCalled(); + }); + + it('should handle async errors in focus revalidators gracefully', () => { + const errorFn = jest.fn().mockRejectedValue(new Error('Focus error')); + const workingFn = jest.fn().mockResolvedValue('success'); + + addRevalidator(testKey, errorFn, undefined, undefined, errorFn, true); + addRevalidator( + testKey2, + workingFn, + undefined, + undefined, + workingFn, + true, + ); + + // Should not throw despite error in one revalidator + expect(() => { + if (focusHandler) { + focusHandler(); + } + }).not.toThrow(); + + // With fake timers, the revalidators should be called immediately + expect(errorFn).toHaveBeenCalledTimes(1); + expect(workingFn).toHaveBeenCalledTimes(1); + }); + + it('should not trigger manual revalidators on focus events', () => { + addRevalidator(testKey, mockRevalidatorFn); + addRevalidator(testKey2, mockRevalidatorFn2); + + if (focusHandler) { + focusHandler(); + } + + // Manual revalidators should not be called + expect(mockRevalidatorFn).not.toHaveBeenCalled(); + expect(mockRevalidatorFn2).not.toHaveBeenCalled(); + }); + + it('should handle rapid focus events without issues', () => { + addRevalidator( + testKey, + mockRevalidatorFn, + 5000, + undefined, + mockRevalidatorFn, + true, + ); + + // Trigger multiple focus events rapidly (reduced from 5 to 3) + if (focusHandler) { + for (let i = 0; i < 3; i++) { + focusHandler(); + jest.advanceTimersByTime(5); // Reduced time advance + } + } + + // Should be called for each focus event + expect(mockRevalidatorFn).toHaveBeenCalledTimes(3); + }); + }); + + describe('additional tuple-based revalidator tests', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('should handle TTL = 0 as never expire', async () => { + addRevalidator(testKey, mockRevalidatorFn, 0); + + const cleanup = startRevalidatorCleanup(50); // Reduced cleanup interval + + // Reduced time advance for TTL=0 test + jest.advanceTimersByTime(60 * 60 * 1000); // 1 hour instead of 24 + + await revalidate(testKey); + expect(mockRevalidatorFn).toHaveBeenCalledTimes(1); + + cleanup(); + }); + + it('should clean up expired entries in periodic cleanup', async () => { + addRevalidator(testKey, mockRevalidatorFn, 100); + + const cleanup = startRevalidatorCleanup(50); + jest.advanceTimersByTime(200); + + const result = await revalidate(testKey); + expect(result).toBeNull(); + + cleanup(); + }); + + it('should handle both manual and focus revalidators for same key', async () => { + const manualFn = jest.fn().mockResolvedValue('manual'); + const focusFn = jest.fn().mockResolvedValue('focus'); + + addRevalidator(testKey, manualFn); + addRevalidator(testKey, focusFn, undefined, undefined, undefined, true); + + const result = await revalidate(testKey); + expect(focusFn).toHaveBeenCalledTimes(1); + expect(manualFn).not.toHaveBeenCalled(); + expect(result).toBe('focus'); + }); + + it('should handle extreme TTL values', async () => { + addRevalidator(testKey, mockRevalidatorFn, Number.MAX_SAFE_INTEGER); + addRevalidator(testKey2, mockRevalidatorFn2, -1000); + + await revalidate(testKey); + await revalidate(testKey2); + + expect(mockRevalidatorFn).toHaveBeenCalledTimes(1); + expect(mockRevalidatorFn2).toHaveBeenCalledTimes(1); + }); + + it('should handle keys containing focus suffix', async () => { + const specialKey = 'test|f|more'; + addRevalidator(specialKey, mockRevalidatorFn); + addRevalidator( + specialKey, + mockRevalidatorFn2, + undefined, + undefined, + undefined, + true, + ); + + await revalidate(specialKey); + expect(mockRevalidatorFn2).toHaveBeenCalledTimes(1); + + removeRevalidator(specialKey); + const result = await revalidate(specialKey); + expect(result).toBeNull(); + }); + + it('should handle self-modifying revalidators', async () => { + const selfModifyingFn = jest.fn().mockImplementation(() => { + removeRevalidator(testKey); + return 'modified'; + }); + + addRevalidator(testKey, selfModifyingFn); + + const result1 = await revalidate(testKey); + const result2 = await revalidate(testKey); + + expect(result1).toBe('modified'); + expect(result2).toBeNull(); + }); + }); + + describe('staleTime functionality', () => { + beforeEach(() => { + jest.useFakeTimers(); + // Mock global fetch for staleTime tests + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ data: 'mocked response' }), + }); + }); + + afterEach(() => { + removeRevalidator(testKey); + removeRevalidator(testKey2); + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('should trigger background revalidation after staleTime', async () => { + const mainFn = jest.fn().mockResolvedValue('main'); + const bgFn = jest.fn().mockResolvedValue('background'); + const staleTime = 1000; + + addRevalidator(testKey, mainFn, 3 * 60 * 1000, staleTime, bgFn); + + // Initially no background revalidation + expect(bgFn).not.toHaveBeenCalled(); + + // Advance time past staleTime + jest.advanceTimersByTime(staleTime + 10); + + // Run all pending timers and microtasks + jest.runAllTimers(); + await Promise.resolve(); // Allow microtasks to complete + + expect(bgFn).toHaveBeenCalledTimes(1); + expect(mainFn).not.toHaveBeenCalled(); + }); + + it('should not trigger background revalidation when staleTime is 0', async () => { + const mainFn = jest.fn().mockResolvedValue('main'); + const bgFn = jest.fn().mockResolvedValue('background'); + + addRevalidator(testKey, mainFn, 3 * 60 * 1000, 0, bgFn); + + jest.advanceTimersByTime(10000); + jest.runAllTimers(); + await Promise.resolve(); + + expect(bgFn).not.toHaveBeenCalled(); + }); + + it('should clean up stale timer on unregister', async () => { + const mainFn = jest.fn().mockResolvedValue('main'); + const bgFn = jest.fn().mockResolvedValue('background'); + const staleTime = 1000; + + addRevalidator(testKey, mainFn, 3 * 60 * 1000, staleTime, bgFn); + removeRevalidator(testKey); + + // Advance time past staleTime + jest.advanceTimersByTime(staleTime + 10); + jest.runAllTimers(); + await Promise.resolve(); + + expect(bgFn).not.toHaveBeenCalled(); + }); + + it('should handle background revalidation errors silently', async () => { + const mainFn = jest.fn().mockResolvedValue('main'); + const bgFn = jest.fn().mockRejectedValue(new Error('Background error')); + const staleTime = 1000; + + addRevalidator(testKey, mainFn, 3 * 60 * 1000, staleTime, bgFn); + + // Should not throw despite background error + expect(() => { + jest.advanceTimersByTime(staleTime + 10); + }).not.toThrow(); + + jest.runAllTimers(); + await Promise.resolve(); + expect(bgFn).toHaveBeenCalledTimes(1); + }); + + it('should use background revalidator when isBgRevalidator is true', async () => { + const mainFn = jest.fn().mockResolvedValue('main'); + const bgFn = jest.fn().mockResolvedValue('background'); + + addRevalidator(testKey, mainFn, 3 * 60 * 1000, 0, bgFn); + + const result = await revalidate(testKey, true); + expect(bgFn).toHaveBeenCalledTimes(1); + expect(mainFn).not.toHaveBeenCalled(); + expect(result).toBe('background'); + }); + + it('should return null when background revalidator is not defined', async () => { + const mainFn = jest.fn().mockResolvedValue('main'); + + addRevalidator(testKey, mainFn, 3 * 60 * 1000, 0); + + const result = await revalidate(testKey, true); + expect(result).toBeNull(); + expect(mainFn).not.toHaveBeenCalled(); + }); + + it('should handle multiple stale timers for different keys', async () => { + const fn1 = jest.fn().mockResolvedValue('fn1'); + const fn2 = jest.fn().mockResolvedValue('fn2'); + const bgFn1 = jest.fn().mockResolvedValue('bg1'); + const bgFn2 = jest.fn().mockResolvedValue('bg2'); + const staleTime1 = 1; + const staleTime2 = 2; + + addRevalidator(testKey, fn1, 3 * 60 * 1000, staleTime1, bgFn1); + addRevalidator(testKey2, fn2, 3 * 60 * 1000, staleTime2, bgFn2); + + // Advance time past first staleTime + jest.advanceTimersByTime(staleTime1 * 1000 + 10); + + expect(bgFn1).toHaveBeenCalledTimes(1); + expect(bgFn2).not.toHaveBeenCalled(); + + // Advance time past second staleTime + jest.advanceTimersByTime(staleTime2 * 1000 - staleTime1 * 1000); + + expect(bgFn1).toHaveBeenCalledTimes(1); + expect(bgFn2).toHaveBeenCalledTimes(1); + }); + + it('should handle fetch-based background revalidation', async () => { + const fetchSpy = global.fetch as jest.MockedFunction; + fetchSpy.mockResolvedValueOnce({ + ok: true, + status: 200, + json: jest.fn().mockResolvedValue({ data: 'fresh data' }), + } as unknown as Response); + + // Create a realistic revalidator that uses fetch + const bgRevalidator = jest.fn().mockImplementation(async () => { + const response = await fetch('/api/test'); + return response.json(); + }); + + const mainFn = jest.fn().mockResolvedValue('main'); + const staleTime = 1000; + + addRevalidator(testKey, mainFn, 3 * 60 * 1000, staleTime, bgRevalidator); + + // Advance time past staleTime + jest.advanceTimersByTime(staleTime + 10); + jest.runAllTimers(); + await Promise.resolve(); + + expect(bgRevalidator).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith('/api/test'); + }); + + it('should handle network errors in background revalidation', async () => { + const fetchSpy = global.fetch as jest.MockedFunction; + fetchSpy.mockRejectedValueOnce(new Error('Network error')); + + // Create a revalidator that handles fetch errors + const bgRevalidator = jest.fn().mockImplementation(async () => { + try { + const response = await fetch('/api/test'); + return response.json(); + } catch { + throw new Error('Revalidation failed'); + } + }); + + const mainFn = jest.fn().mockResolvedValue('main'); + const staleTime = 1000; + + addRevalidator(testKey, mainFn, 3 * 60 * 1000, staleTime, bgRevalidator); + + // Advance time past staleTime + jest.advanceTimersByTime(staleTime + 10); + jest.runAllTimers(); + await Promise.resolve(); + + expect(bgRevalidator).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith('/api/test'); + }); + }); + + describe('additional edge cases and error conditions', () => { + it('should handle revalidator function that returns undefined', async () => { + const undefinedFn = jest.fn().mockResolvedValue(undefined); + addRevalidator(testKey, undefinedFn); + + const result = await revalidate(testKey); + expect(undefinedFn).toHaveBeenCalledTimes(1); + expect(result).toBeUndefined(); + }); + + it('should handle very large TTL values without overflow', async () => { + const largeTTL = Number.MAX_SAFE_INTEGER; + addRevalidator(testKey, mockRevalidatorFn, largeTTL); + + await revalidate(testKey); + expect(mockRevalidatorFn).toHaveBeenCalledTimes(1); + }); + + it('should handle keys with special characters', async () => { + const specialKey = 'test-key/with:special@chars#and%encoding'; + addRevalidator(specialKey, mockRevalidatorFn); + + await revalidate(specialKey); + expect(mockRevalidatorFn).toHaveBeenCalledTimes(1); + + removeRevalidator(specialKey); + const result = await revalidate(specialKey); + expect(result).toBeNull(); + }); + + it('should handle revalidator that modifies global state', async () => { + let globalCounter = 0; + const statefulFn = jest.fn().mockImplementation(() => { + globalCounter++; + return Promise.resolve(`count-${globalCounter}`); + }); + + addRevalidator(testKey, statefulFn); + + const result1 = await revalidate(testKey); + const result2 = await revalidate(testKey); + + expect(result1).toBe('count-1'); + expect(result2).toBe('count-2'); + expect(globalCounter).toBe(2); + }); + + it('should handle simultaneous revalidations of same key', async () => { + let callCount = 0; + const slowFn = jest.fn().mockImplementation(() => { + return new Promise((resolve) => + setTimeout(() => { + callCount++; + resolve(`result-${callCount}`); + }, 10), + ); + }); + + addRevalidator(testKey, slowFn); + + // Start multiple revalidations simultaneously + const promise1 = revalidate(testKey); + const promise2 = revalidate(testKey); + const promise3 = revalidate(testKey); + + const [result1, result2, result3] = await Promise.all([ + promise1, + promise2, + promise3, + ]); + + expect(slowFn).toHaveBeenCalledTimes(3); + expect(result1).toBe('result-1'); + expect(result2).toBe('result-2'); + expect(result3).toBe('result-3'); + }); + + it('should handle focus revalidator registration when focus handler not initialized', () => { + // Remove focus handler if it exists + removeRevalidators('focus'); + + // Should not throw when registering focus revalidator without handler + expect(() => { + addRevalidator( + testKey, + mockRevalidatorFn, + undefined, + undefined, + undefined, + true, + ); + }).not.toThrow(); + }); + + it('should handle cleanup with mixed revalidator types', () => { + addRevalidator(testKey, mockRevalidatorFn); + addRevalidator( + testKey, + mockRevalidatorFn2, + undefined, + undefined, + undefined, + true, + ); + + // Should clean up both types + expect(() => { + removeRevalidator(testKey); + }).not.toThrow(); + }); + + it('should handle extremely short stale times', async () => { + jest.useRealTimers(); // Use real timers for this test + + const mainFn = jest.fn().mockResolvedValue('main'); + const bgFn = jest.fn().mockResolvedValue('background'); + const shortStaleTime = 0.001; // 1ms + + addRevalidator(testKey, mainFn, 3 * 60 * 1000, shortStaleTime, bgFn); + + // Wait slightly longer than stale time + await new Promise((resolve) => setTimeout(resolve, 5)); + + expect(bgFn).toHaveBeenCalledTimes(1); + + jest.useFakeTimers(); // Restore fake timers + }); + }); +}); diff --git a/test/timeout-wheel.spec.ts b/test/timeout-wheel.spec.ts new file mode 100644 index 00000000..1139c8b9 --- /dev/null +++ b/test/timeout-wheel.spec.ts @@ -0,0 +1,468 @@ +/** + * @jest-environment jsdom + */ +import { + addTimeout, + removeTimeout, + clearAllTimeouts, +} from '../src/timeout-wheel'; + +describe('Timeout Wheel', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + clearAllTimeouts(); + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + describe('Basic Functionality', () => { + it('should execute timeout after specified time', () => { + const callback = jest.fn(); + + addTimeout('test-1', callback, 3000); + + // Should not fire before time + expect(callback).not.toHaveBeenCalled(); + + // Advance to just before timeout + jest.advanceTimersByTime(2900); + expect(callback).not.toHaveBeenCalled(); + + // Advance past timeout + jest.advanceTimersByTime(100); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should execute multiple timeouts at correct times', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + const callback3 = jest.fn(); + + addTimeout('test-1', callback1, 1000); + addTimeout('test-2', callback2, 3000); + addTimeout('test-3', callback3, 5000); + + // At 1 second + jest.advanceTimersByTime(1000); + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).not.toHaveBeenCalled(); + expect(callback3).not.toHaveBeenCalled(); + + // At 3 seconds + jest.advanceTimersByTime(2000); + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback3).not.toHaveBeenCalled(); + + // At 5 seconds + jest.advanceTimersByTime(2000); + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback3).toHaveBeenCalledTimes(1); + }); + + it('should remove timeout before execution', () => { + const callback = jest.fn(); + + addTimeout('test-1', callback, 3000); + removeTimeout('test-1'); + + // Advance past timeout time + jest.advanceTimersByTime(5000); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should handle removing non-existent timeout', () => { + expect(() => removeTimeout('non-existent')).not.toThrow(); + }); + }); + + describe('Key Management', () => { + it('should replace timeout with same key', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + addTimeout('same-key', callback1, 2000); + addTimeout('same-key', callback2, 4000); + + // First timeout should be replaced + jest.advanceTimersByTime(2500); + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + + // Second timeout should fire + jest.advanceTimersByTime(2000); + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).toHaveBeenCalledTimes(1); + }); + + it('should handle multiple timeouts in same slot', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + const callback3 = jest.fn(); + + addTimeout('key-1', callback1, 3000); + addTimeout('key-2', callback2, 3000); + addTimeout('key-3', callback3, 3000); + + jest.advanceTimersByTime(3000); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + expect(callback3).toHaveBeenCalledTimes(1); + }); + + it('should remove specific timeout from slot with multiple timeouts', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + const callback3 = jest.fn(); + + addTimeout('key-1', callback1, 3000); + addTimeout('key-2', callback2, 3000); + addTimeout('key-3', callback3, 3000); + + removeTimeout('key-2'); + + jest.advanceTimersByTime(3000); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).not.toHaveBeenCalled(); + expect(callback3).toHaveBeenCalledTimes(1); + }); + }); + + describe('Fallback to setTimeout', () => { + it('should use setTimeout for timeouts > 10 minutes', () => { + const callback = jest.fn(); + const longTimeout = 11 * 60 * 1000; // 11 minutes + + addTimeout('long-timeout', callback, longTimeout); + + // Should use native setTimeout, not timing wheel + jest.advanceTimersByTime(longTimeout); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should use setTimeout for non-1000ms-divisible timeouts', () => { + const callback = jest.fn(); + + addTimeout('sub-second', callback, 1500); // 1.5 seconds + + jest.advanceTimersByTime(1500); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should remove setTimeout-based timeouts', () => { + const callback = jest.fn(); + + addTimeout('sub-second', callback, 1500); + removeTimeout('sub-second'); + + jest.advanceTimersByTime(2000); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should handle replacing setTimeout timeout with wheel timeout', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + addTimeout('mixed-key', callback1, 1500); // setTimeout + addTimeout('mixed-key', callback2, 3000); // wheel + + jest.advanceTimersByTime(1600); + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1500); + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).toHaveBeenCalledTimes(1); + }); + }); + + describe('Timer Management', () => { + it('should start timer on first timeout', () => { + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + + addTimeout('test-1', jest.fn(), 3000); + + expect(setIntervalSpy).toHaveBeenCalledWith(expect.any(Function), 1000); + }); + + it('should stop timer when no timeouts remain', () => { + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + const callback = jest.fn(); + + addTimeout('test-1', callback, 1000); + + // Timer should be running + expect(clearIntervalSpy).not.toHaveBeenCalled(); + + // Fire timeout + jest.advanceTimersByTime(1000); + + // Timer should be stopped + expect(clearIntervalSpy).toHaveBeenCalled(); + }); + + it('should not start multiple timers', () => { + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + + addTimeout('test-1', jest.fn(), 2000); + addTimeout('test-2', jest.fn(), 3000); + addTimeout('test-3', jest.fn(), 4000); + + expect(setIntervalSpy).toHaveBeenCalledTimes(1); + }); + + it('should restart timer after clearAllTimeouts', () => { + const setIntervalSpy = jest.spyOn(global, 'setInterval'); + + addTimeout('test-1', jest.fn(), 2000); + clearAllTimeouts(); + addTimeout('test-2', jest.fn(), 3000); + + expect(setIntervalSpy).toHaveBeenCalledTimes(2); + }); + }); + + describe('Error Handling', () => { + it('should handle callback errors without stopping wheel', () => { + const errorCallback = jest.fn(() => { + throw new Error('Callback error'); + }); + const normalCallback = jest.fn(); + + addTimeout('error-key', errorCallback, 2000); + addTimeout('normal-key', normalCallback, 3000); + + // Error callback should throw but not stop wheel + jest.advanceTimersByTime(2000); + expect(errorCallback).toHaveBeenCalledTimes(1); + + // Normal callback should still work + jest.advanceTimersByTime(1000); + expect(normalCallback).toHaveBeenCalledTimes(1); + }); + + it('should handle async callback errors', () => { + const asyncErrorCallback = jest.fn(async () => { + throw new Error('Async error'); + }); + const normalCallback = jest.fn(); + + addTimeout('async-error', asyncErrorCallback, 2000); + addTimeout('normal', normalCallback, 3000); + + jest.advanceTimersByTime(2000); + expect(asyncErrorCallback).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(1000); + expect(normalCallback).toHaveBeenCalledTimes(1); + }); + }); + + describe('Edge Cases', () => { + it('should handle zero timeout', () => { + const callback = jest.fn(); + + addTimeout('zero', callback, 0); + + jest.advanceTimersByTime(1); + expect(callback).toHaveBeenCalledTimes(0); // Should not execute immediately + }); + + it('should handle timeout at wheel boundary', () => { + const callback = jest.fn(); + const maxWheelTime = 600 * 1000; // 10 minutes + + addTimeout('boundary', callback, maxWheelTime); + + jest.advanceTimersByTime(maxWheelTime); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should handle position wraparound', () => { + const callbacks: jest.Mock[] = []; + + // Add timeouts to force position wraparound + for (let i = 0; i < 605; i++) { + const callback = jest.fn(); + callbacks.push(callback); + addTimeout(`key-${i}`, callback, (i + 1) * 1000); + } + + // Advance 605 seconds to force wraparound + jest.advanceTimersByTime(605 * 1000); + + // All callbacks should have fired + callbacks.forEach((callback) => { + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + it('should handle removing timeout during execution', () => { + const callback1 = jest.fn(() => { + removeTimeout('key-2'); // Remove another timeout during execution + }); + const callback2 = jest.fn(); + + addTimeout('key-1', callback1, 3000); + addTimeout('key-2', callback2, 3000); + + jest.advanceTimersByTime(3000); + + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).not.toHaveBeenCalled(); + }); + }); + + describe('Performance', () => { + it('should handle many timeouts efficiently', () => { + const callbacks: jest.Mock[] = []; + const timeoutCount = 1000; + + // Add 1000 timeouts + for (let i = 0; i < timeoutCount; i++) { + const callback = jest.fn(); + callbacks.push(callback); + addTimeout(`perf-key-${i}`, callback, ((i % 600) + 1) * 1000); + } + + // Advance time to execute all timeouts + jest.advanceTimersByTime(600 * 1000); + + // All callbacks should have fired + callbacks.forEach((callback) => { + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + it('should batch execute timeouts in same slot', () => { + const callbacks: jest.Mock[] = []; + const sameSlotCount = 100; + + // Add 100 timeouts to same slot + for (let i = 0; i < sameSlotCount; i++) { + const callback = jest.fn(); + callbacks.push(callback); + addTimeout(`batch-key-${i}`, callback, 5000); + } + + jest.advanceTimersByTime(5000); + + // All callbacks should fire together + callbacks.forEach((callback) => { + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('clearAllTimeouts', () => { + it('should clear all wheel timeouts', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + const callback3 = jest.fn(); + + addTimeout('key-1', callback1, 2000); + addTimeout('key-2', callback2, 4000); + addTimeout('key-3', callback3, 6000); + + clearAllTimeouts(); + + jest.advanceTimersByTime(10000); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + expect(callback3).not.toHaveBeenCalled(); + }); + + it('should clear setTimeout-based timeouts', () => { + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + addTimeout('wheel-timeout', callback1, 3000); + addTimeout('settimeout-timeout', callback2, 1500); + + clearAllTimeouts(); + + jest.advanceTimersByTime(5000); + + expect(callback1).not.toHaveBeenCalled(); + expect(callback2).not.toHaveBeenCalled(); + }); + + it('should reset wheel position', () => { + const callback = jest.fn(); + + // Advance wheel position + addTimeout('advance', jest.fn(), 1000); + jest.advanceTimersByTime(1000); + + clearAllTimeouts(); + + // Add new timeout - should start from position 0 + addTimeout('reset-test', callback, 1000); + jest.advanceTimersByTime(1000); + + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should stop wheel timer', () => { + const clearIntervalSpy = jest.spyOn(global, 'clearInterval'); + + addTimeout('test', jest.fn(), 5000); + clearAllTimeouts(); + + expect(clearIntervalSpy).toHaveBeenCalled(); + }); + }); + + describe('Integration Scenarios', () => { + it('should handle rapid add/remove cycles', () => { + const callback = jest.fn(); + + for (let i = 0; i < 100; i++) { + addTimeout('rapid-key', callback, 3000); + if (i % 2 === 0) { + removeTimeout('rapid-key'); + } + } + + jest.advanceTimersByTime(3000); + + // Only the last timeout should fire + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should maintain correct execution order', () => { + const executionOrder: number[] = []; + + addTimeout('third', () => executionOrder.push(3), 3000); + addTimeout('first', () => executionOrder.push(1), 1000); + addTimeout('second', () => executionOrder.push(2), 2000); + addTimeout('fourth', () => executionOrder.push(4), 4000); + + jest.advanceTimersByTime(5000); + + expect(executionOrder).toEqual([1, 2, 3, 4]); + }); + + it('should handle mixed wheel and setTimeout timeouts', () => { + const wheelCallback = jest.fn(); + const setTimeoutCallback = jest.fn(); + + addTimeout('wheel', wheelCallback, 2000); // Uses wheel + addTimeout('setTimeout', setTimeoutCallback, 2500); // Uses setTimeout + + jest.advanceTimersByTime(2000); + expect(wheelCallback).toHaveBeenCalledTimes(1); + expect(setTimeoutCallback).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(500); + expect(setTimeoutCallback).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/docs/examples/examples.ts b/test/types/examples.ts similarity index 82% rename from docs/examples/examples.ts rename to test/types/examples.ts index 04d46e86..3f17453a 100644 --- a/docs/examples/examples.ts +++ b/test/types/examples.ts @@ -3,6 +3,7 @@ */ import { createApiFetcher, fetchf } from '../../src'; import type { Endpoint } from '../../src/types'; +import type { Req } from '../../src/types/request-handler'; const endpoints = { ping: { url: 'ping' }, @@ -63,7 +64,7 @@ async function example1() { console.log('Example 1', data, apiConfig, endpointsList); } -// With passed "typeof endpoints" to createApiFetcher() +// Without passed "typeof endpoints" to createApiFetcher() async function example2() { const api = createApiFetcher({ apiUrl: '', @@ -78,7 +79,7 @@ async function example2() { // @ts-expect-error Endpoint ping2 does not exist await api.ping2(); - const { data } = await api.ping<{ dd: string }>(); + const { data } = await api.ping<{ response: { dd: string } }>(); console.log('Example 2', data, apiConfig, endpointsList); } @@ -87,8 +88,15 @@ async function example2() { async function example3() { // Note how you do not need to specify all endpoints for typings to work just fine. interface Endpoints { - fetchBook: Endpoint; - fetchBooks: Endpoint; + fetchBook: Endpoint<{ + response: Book; + params: BookQueryParams; + urlPathParams: BookPathParams; + }>; + fetchBooks: Endpoint<{ + response: Books; + params: BooksQueryParams; + }>; } type EndpointsConfiguration = typeof endpoints; @@ -114,11 +122,11 @@ async function example3() { const { data: movies1 } = await api.fetchMovies(); // With dynamically inferred type - const { data: movies } = await api.fetchMovies(); - const { data: movies3 }: { data: Movies } = await api.fetchMovies(); + const { data: movies } = await api.fetchMovies<{ response: Movies }>(); + const { data: movies3 } = await api.fetchMovies>(); // With custom params not defined in any interface - const { data: movies4 } = await api.fetchMovies({ + const { data: movies4 }: { data: Movies } = await api.fetchMovies({ params: { all: true, }, @@ -136,19 +144,35 @@ async function example3() { } // Overwrite response of existing endpoint - const { data: book1 } = await api.fetchBook( - { newBook: true }, + const { data: book1 } = await api.fetchBook<{ + response: NewBook; + params: BookQueryParams; + }>({ + params: { newBook: true }, // @ts-expect-error should verify that bookId cannot be text - { bookId: 'text' }, - ); + urlPathParams: { bookId: 'text' }, + }); // Overwrite response and query params of existing endpoint - const { data: book11 } = await api.fetchBook({ + const { data: book11 } = await api.fetchBook<{ + response: NewBook; + params: NewBookQueryParams; + }>({ params: { // @ts-expect-error Should not allow old param newBook: true, color: 'green', - // TODO: @ts-expect-error Should not allow non-existent param + }, + }); + + // Overwrite response and query params of existing endpoint + const { data: book12 } = await api.fetchBook<{ + response: NewBook; + params: NewBookQueryParams; + }>({ + params: { + color: 'green', + // @ts-expect-error Should not allow non-existent param type: 'red', }, }); @@ -185,6 +209,7 @@ async function example3() { book satisfies Book, book1 satisfies NewBook, book11 satisfies NewBook, + book12 satisfies NewBook, book2 satisfies Book, book3 satisfies Book, ); @@ -193,7 +218,10 @@ async function example3() { // createApiFetcher() - direct API request() call to a custom endpoint with flattenResponse == true async function example4() { interface Endpoints { - fetchBooks: Endpoint; + fetchBooks: Endpoint<{ + response: Books; + params: BooksQueryParams; + }>; } type EndpointsConfiguration = typeof endpoints; @@ -205,7 +233,7 @@ async function example4() { }); // Existing endpoint generic - const { data: books } = await api.request('fetchBooks'); + const { data: books } = await api.request<{ response: Books }>('fetchBooks'); // Custom URL const { data: data1 } = await api.request( @@ -224,7 +252,7 @@ async function example4() { }); // Dynamically added Response to a generic - const { data: data2 } = await api.request( + const { data: data2 } = await api.request<{ response: OtherEndpointData }>( 'https://example.com/api/custom-endpoint', ); @@ -242,11 +270,11 @@ async function example4() { urlparam2: number; } - const { data: books2 } = await api.request< - Books, - DynamicQueryParams, - DynamicUrlParams - >('fetchBooks', { + const { data: books2 } = await api.request<{ + response: Books; + params: DynamicQueryParams; + urlPathParams: DynamicUrlParams; + }>('fetchBooks', { // Native fetch() setting cache: 'no-store', // Extended fetch setting @@ -278,7 +306,10 @@ async function example4() { // createApiFetcher() - direct API request() call to a custom endpoint with flattenResponse == false async function example5() { interface MyEndpoints { - fetchBooks: Endpoint; + fetchBooks: Endpoint<{ + response: Books; + params: BooksQueryParams; + }>; } type EndpointsConfiguration = typeof endpoints; @@ -289,13 +320,16 @@ async function example5() { }); const { data: books2 } = await api.fetchBooks(); - const { data: books } = await api.request('fetchBooks', {}); + const { data: books } = await api.request<{ response: Books }>( + 'fetchBooks', + {}, + ); const { data: data1 } = await api.request( 'https://example.com/api/custom-endpoint', ); // Specify generic - const { data: data2 } = await api.request<{ myData: true }>( + const { data: data2 } = await api.request<{ response: { myData: true } }>( 'https://example.com/api/custom-endpoint', ); diff --git a/test/utils.spec.ts b/test/utils.spec.ts index 69241620..6aa21471 100644 --- a/test/utils.spec.ts +++ b/test/utils.spec.ts @@ -8,6 +8,17 @@ import { sanitizeObject, } from '../src/utils'; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(global as any).File = class File { + name: string; + type: string; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + constructor(_parts: any[], name: string, options: any = {}) { + this.name = name; + this.type = options.type || ''; + } +}; + describe('Utils', () => { console.warn = jest.fn(); @@ -45,19 +56,19 @@ describe('Utils', () => { expect(output).toEqual(input); // Same content }); - it('should handle null and undefined inputs', () => { + xit('should handle null and undefined inputs', () => { // @ts-expect-error Null and undefined are not objects expect(sanitizeObject(null)).toBeNull(); // @ts-expect-error Null and undefined are not objects expect(sanitizeObject(undefined)).toBeUndefined(); }); - it('should handle array inputs without modification', () => { + xit('should handle array inputs without modification', () => { const input = [1, 2, 3]; expect(sanitizeObject(input)).toEqual(input); }); - it('should handle primitive inputs without modification', () => { + xit('should handle primitive inputs without modification', () => { // @ts-expect-error String, number, and boolean are not objects expect(sanitizeObject('string')).toBe('string'); // @ts-expect-error String, number, and boolean are not objects @@ -166,8 +177,6 @@ describe('Utils', () => { const input = { normal: 'safe', __proto__: { polluted: true }, - constructor: 'unsafe', - prototype: 'danger', }; const output = sortObject(input); @@ -195,6 +204,81 @@ describe('Utils', () => { }); describe('isJSONSerializable()', () => { + describe('Body instances that should NOT be JSON serializable', () => { + it('should return false for FormData', () => { + const formData = new FormData(); + formData.append('key', 'value'); + expect(isJSONSerializable(formData)).toBe(false); + }); + + it('should return false for URLSearchParams', () => { + const params = new URLSearchParams(); + params.append('key', 'value'); + expect(isJSONSerializable(params)).toBe(false); + }); + + it('should return false for Blob', () => { + const blob = new Blob(['content'], { type: 'text/plain' }); + expect(isJSONSerializable(blob)).toBe(false); + }); + + it('should return false for File', () => { + const file = new File(['content'], 'test.txt', { + type: 'text/plain', + }); + expect(isJSONSerializable(file)).toBe(false); + }); + + it('should return false for ArrayBuffer', () => { + const buffer = new ArrayBuffer(8); + expect(isJSONSerializable(buffer)).toBe(false); + }); + + it('should return false for TypedArrays', () => { + expect(isJSONSerializable(new Uint8Array([1, 2, 3]))).toBe(false); + expect(isJSONSerializable(new Int16Array([1, 2, 3]))).toBe(false); + expect(isJSONSerializable(new Float32Array([1.5, 2.5]))).toBe(false); + expect(isJSONSerializable(new Uint32Array([1, 2, 3]))).toBe(false); + }); + + it('should return false for ReadableStream', () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue('chunk'); + controller.close(); + }, + }); + expect(isJSONSerializable(stream)).toBe(false); + }); + + it('should return false for Request object', () => { + const request = new Request('https://example.com'); + expect(isJSONSerializable(request)).toBe(false); + }); + + it('should return false for Response object', () => { + const response = new Response('content'); + expect(isJSONSerializable(response)).toBe(false); + }); + + it('should return false for DataView', () => { + const buffer = new ArrayBuffer(8); + const view = new DataView(buffer); + expect(isJSONSerializable(view)).toBe(false); + }); + + it('should return false for Error objects', () => { + expect(isJSONSerializable(new Error('test'))).toBe(false); + expect(isJSONSerializable(new TypeError('test'))).toBe(false); + expect(isJSONSerializable(new RangeError('test'))).toBe(false); + }); + + it('should return false for RegExp', () => { + expect(isJSONSerializable(/test/g)).toBe(false); + expect(isJSONSerializable(new RegExp('test', 'i'))).toBe(false); + }); + }); + it('should return false for undefined', () => { expect(isJSONSerializable(undefined)).toBe(false); }); diff --git a/test/utils/mockFetchResponse.ts b/test/utils/mockFetchResponse.ts new file mode 100644 index 00000000..a27265fe --- /dev/null +++ b/test/utils/mockFetchResponse.ts @@ -0,0 +1,141 @@ +/** + * When testing in browser env, the polyfills for fetch and streams like whatwg-fetch could be used. They are slow and give unexpected results with fetch-mock. + * This mock implementation is used to simulate fetch responses in tests. + * It allows you to specify the URL and configuration overrides for the mock response. + * @param urlOverride - The URL to override the fetch request with. + * @param configOverride - The configuration overrides for the mock response, such as body, status, headers, etc. + * @returns A mocked fetch function that returns a response object with the specified overrides. + */ +// Store mock responses per URL +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const mockResponses = new Map(); + +export const mockFetchResponse = ( + urlOverride: string, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + configOverride: any = {}, + outputError = false, +) => { + // Store the mock config for this URL + mockResponses.set(urlOverride, configOverride); + + // Create or update the global fetch mock + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const fetchMock = (url: string, config: any) => { + // Find the mock config for this URL + const mockConfig = mockResponses.get(url) || {}; + const hasUrl = mockResponses.has(url); + + if (!hasUrl || outputError) { + if (!hasUrl) { + console.warn('No mock response configured for URL: ' + url); + } + + return Promise.resolve({ + url, + ok: false, + status: config?.status ?? 404, + headers: { 'Content-Type': 'application/json' }, + data: config?.data ?? null, + body: config?.body ?? undefined, + method: config?.method ?? 'GET', + }); + } + + const response = { + url: url || urlOverride, + body: mockConfig.body ? JSON.stringify(mockConfig.body) : undefined, + data: mockConfig.body ?? null, + requestBody: config?.body ?? undefined, + ok: mockConfig.ok ?? true, + status: mockConfig.status ?? 200, + headers: mockConfig.headers ?? + config?.headers ?? { + 'Content-Type': 'application/json', + }, + json: () => Promise.resolve(mockConfig.body), + text: () => + Promise.resolve(mockConfig.body ? JSON.stringify(mockConfig.body) : ''), + config, + ...mockConfig, + }; + + return Promise.resolve(response); + }; + // @ts-expect-error Global override for fetch for benchmarks + global.fetch = + typeof jest !== 'undefined' + ? jest.fn().mockImplementation(fetchMock) + : fetchMock; + + return global.fetch; +}; + +// Utility to clear all mock responses +export const clearMockResponses = () => { + mockResponses.clear(); + if (global.fetch && typeof global.fetch === 'function') { + (global.fetch as jest.Mock).mockClear?.(); + } +}; + +// Utility to get all registered mock URLs (useful for debugging) +export const getMockUrls = () => Array.from(mockResponses.keys()); + +// Utility to check if a URL has a mock configured +export const hasMockForUrl = (url: string) => mockResponses.has(url); + +// Create a helper for AbortController-aware mocks +export const createAbortableFetchMock = ( + delay: number = 2000, + shouldResolve: boolean = true, + mockData: unknown = null, +) => { + return jest.fn().mockImplementation((url, options) => { + const signal = options?.signal as AbortSignal | null; + + return new Promise((resolve, reject) => { + // Check if already aborted + if (signal?.aborted) { + reject(new DOMException('Request is already aborted', 'AbortError')); + return; + } + + // Set up abort handling + const abortHandler = () => { + reject( + new DOMException( + 'Request was aborted: ' + signal?.reason.message, + 'AbortError', + ), + ); + }; + + signal?.addEventListener('abort', abortHandler); + + // Simulate request + const timeoutId = setTimeout(() => { + signal?.removeEventListener('abort', abortHandler); + + if (shouldResolve) { + resolve( + mockData || { + ok: true, + status: 200, + body: { url, completed: true }, + // Since it is not a Response object, we need to mock the response.data as well. + data: { url, completed: true }, + }, + ); + } else { + reject(new Error('Network Error due to timeout')); + } + }, delay); + + // Clean up on abort + signal?.addEventListener('abort', () => { + clearTimeout(timeoutId); + }); + }); + }); +}; diff --git a/tsconfig.json b/tsconfig.json index 23421ebb..ae0d4549 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,13 @@ "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, - "noEmit": true + "noEmit": true, + "baseUrl": ".", + "jsx": "react-jsx", + "types": ["node", "jest", "@testing-library/jest-dom"], + "paths": { + "fetchff/*": ["./src/*"], + "fetchff": ["./src/index.ts"] + } } } diff --git a/tsup.config.ts b/tsup.config.ts index 34b5b0b5..92e44aca 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,13 +1,52 @@ import { defineConfig } from 'tsup'; -export default defineConfig({ - name: 'fetchff', - globalName: 'fetchff', - entry: ['src/index.ts'], - target: 'es2018', - dts: true, - clean: true, - sourcemap: true, - minify: true, - splitting: false, -}); +export default defineConfig([ + { + name: 'fetchff', + globalName: 'fetchff', + entry: ['src/index.ts'], + format: ['esm', 'iife'], + target: 'es2018', + bundle: true, + dts: true, + clean: true, + outDir: 'dist/browser', + platform: 'browser', + sourcemap: true, + minify: true, + treeshake: true, + splitting: true, + }, + { + name: 'fetchff-node', + globalName: 'fetchff', + entry: ['src/index.ts'], + format: ['cjs'], + target: 'node18', + outDir: 'dist/node', + platform: 'node', + sourcemap: true, + minify: true, + treeshake: true, + splitting: false, + dts: false, + clean: false, + }, + { + name: 'fetchff-react', + globalName: 'fetchffReact', + entry: ['src/react/index.ts'], + target: 'es2018', + dts: false, + format: ['esm', 'cjs'], + outDir: 'dist/react', + platform: 'neutral', + sourcemap: true, + clean: false, + minify: true, + treeshake: true, + bundle: true, + splitting: true, + external: ['react', 'react-dom', 'fetchff'], // prevent bundling React + }, +]);