@ametie/vue-muza-use 0.9.1 โ†’ 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,6 +9,14 @@
9
9
 
10
10
  A production-ready composable that eliminates boilerplate and solves the hard problems: race conditions, token refresh queues, automatic retries, and reactive request management. Write less code, ship faster, sleep better.
11
11
 
12
+ > [!IMPORTANT]
13
+ > ### ๐Ÿค– Claude Code โ€” Built-in AI Skill
14
+ >
15
+ > This library ships with a skill that teaches Claude the feature wrapper pattern, naming conventions, and all `UseApiOptions`.
16
+ > Claude will generate correct, architecture-consistent API layer code out of the box โ€” no extra prompting needed.
17
+ >
18
+ > ๐Ÿ“„ **[View skill file โ†’](https://github.com/MortyQ/vue-useApi/blob/main/.claude/skills/use-api/SKILL.md)**
19
+
12
20
  ---
13
21
 
14
22
  ## โœจ Features
@@ -23,11 +31,14 @@ A production-ready composable that eliminates boilerplate and solves the hard pr
23
31
  - ๐Ÿงน **Zero Memory Leaks** โ€” Automatic cleanup of pending requests on component unmount
24
32
  - ๐Ÿ”• **ignoreUpdates** โ€” Atomic updates without triggering intermediate requests
25
33
  - ๐Ÿ—„๏ธ **Response Caching** โ€” In-memory cache with configurable TTL and manual invalidation
34
+ - โšก **Stale-While-Revalidate** โ€” Serve cached data instantly while refreshing silently in the background
35
+ - ๐Ÿ”ฌ **select** โ€” Transform or filter response data declaratively; re-applied on every fetch automatically
26
36
 
27
37
  **Advanced Features** (When you need them):
28
38
  - โ™ป๏ธ **Intelligent Retries** โ€” Lifecycle-aware retry logic with configurable status codes
29
39
  - ๐Ÿ” **JWT Token Management** โ€” Automatic token refresh with request queueing on 401 responses
30
40
  - ๐ŸŽ›๏ธ **Flexible Architecture** โ€” Bring your own Axios instance with full configuration control
41
+ - ๐Ÿช **withCredentials** โ€” Per-request cookie and cross-origin credential control
31
42
 
32
43
  ---
33
44
 
@@ -42,11 +53,13 @@ A production-ready composable that eliminates boilerplate and solves the hard pr
42
53
  - [Watch & Auto-Refetch](#watch--auto-refetch)
43
54
  - [ignoreUpdates โ€” Atomic Updates Without Refetch](#ignoreupdates--atomic-updates-without-refetch)
44
55
  - [Response Caching](#response-caching)
56
+ - [Stale-While-Revalidate (SWR)](#stale-while-revalidate-swr)
45
57
  - [Polling (Background Updates)](#polling-background-updates)
46
58
  - [Error Handling](#error-handling)
47
59
  - [retry โ€” Automatic Request Retry](#retry--automatic-request-retry)
48
60
  - [Loading States](#loading-states)
49
61
  - [Manual Data Updates (mutate)](#manual-data-updates-mutate)
62
+ - [select โ€” Declarative Data Transformation](#select--declarative-data-transformation)
50
63
 
51
64
  **Real-World Examples:**
52
65
  - [Data Table with Pagination](#data-table-with-pagination--sorting)
@@ -679,6 +692,70 @@ The following are intentionally **not** supported in v1:
679
692
 
680
693
  ---
681
694
 
695
+ ### Stale-While-Revalidate (SWR)
696
+
697
+ **TL;DR: Return cached data instantly while fetching fresh data in the background. No loading spinner, no blank screen.**
698
+
699
+ Requires the `cache` option to be set. On a cache hit, the stale data is returned immediately (no `loading: true`, no spinner) while a silent background request runs. Use the `revalidating` ref to show a subtle refresh indicator if needed.
700
+
701
+ On a **cache miss** (first load), the request behaves exactly like a normal request โ€” `loading: true`, no stale data.
702
+
703
+ #### Basic Usage
704
+
705
+ ```vue
706
+ <script setup lang="ts">
707
+ import { useApi } from '@ametie/vue-muza-use'
708
+
709
+ interface User { id: number; name: string }
710
+
711
+ const { data, revalidating } = useApi<User[]>('/users', {
712
+ cache: 'users',
713
+ staleWhileRevalidate: true,
714
+ immediate: true,
715
+ })
716
+ </script>
717
+
718
+ <template>
719
+ <!-- data renders immediately from cache โ€” no blank screen -->
720
+ <ul>
721
+ <li v-for="user in data" :key="user.id">
722
+ {{ user.name }}
723
+ <span v-if="revalidating">โ†ป</span>
724
+ </li>
725
+ </ul>
726
+ </template>
727
+ ```
728
+
729
+ #### SWR vs Normal Cache Hit
730
+
731
+ | | Normal cache hit | SWR cache hit |
732
+ |---|---|---|
733
+ | `loading` | `false` | `false` |
734
+ | `data` | Stale data, no new request | Stale data โ†’ then fresh data |
735
+ | `revalidating` | `false` | `true` while fetching, then `false` |
736
+ | Axios request | **Not made** | **Made** (silent background fetch) |
737
+ | `onBefore` | Not called | **Not called** (silent) |
738
+ | `onSuccess` | Not called | **Called** with fresh response |
739
+ | `onFinish` | Not called | **Called** after background fetch |
740
+
741
+ #### Error Handling
742
+
743
+ If the background revalidation request fails:
744
+ - `revalidating` resets to `false`
745
+ - `error` is set
746
+ - The **stale data is preserved** โ€” your UI doesn't go blank
747
+
748
+ ```typescript
749
+ const { data, revalidating, error } = useApi('/dashboard', {
750
+ cache: 'dashboard',
751
+ staleWhileRevalidate: true,
752
+ immediate: true,
753
+ })
754
+ // data.value stays the cached value even after a failed revalidation
755
+ ```
756
+
757
+ ---
758
+
682
759
  ### Polling (Background Updates)
683
760
 
684
761
  **TL;DR: Keep data fresh with smart polling that automatically pauses when the browser tab is hidden.**
@@ -948,6 +1025,73 @@ const { mutate } = useApi<User[]>('/users', { immediate: true })
948
1025
 
949
1026
  ---
950
1027
 
1028
+ ### select โ€” Declarative Data Transformation
1029
+
1030
+ **TL;DR: Transform, filter, or reshape response data once โ€” it's re-applied automatically on every fetch, polling tick, and SWR revalidation.**
1031
+
1032
+ Use `select` when you want the same transformation applied every time the request fires. Unlike `mutate` (which you call manually), `select` is declared once and runs silently on each response.
1033
+
1034
+ The second generic parameter of `useApi` controls the output type of `select`.
1035
+
1036
+ #### Extract a Nested Field
1037
+
1038
+ APIs that wrap responses in `{ data: [...], meta: {...} }`:
1039
+
1040
+ ```typescript
1041
+ interface ApiResponse { data: User[]; meta: { total: number } }
1042
+ interface User { id: number; name: string }
1043
+
1044
+ const { data } = useApi<ApiResponse, User[]>('/users', {
1045
+ immediate: true,
1046
+ select: (res) => res.data,
1047
+ // data.value is User[], not ApiResponse
1048
+ })
1049
+ ```
1050
+
1051
+ #### Transform Items
1052
+
1053
+ ```typescript
1054
+ interface RawUser { id: number; firstName: string; lastName: string }
1055
+ interface User { id: number; fullName: string }
1056
+
1057
+ const { data } = useApi<RawUser[], User[]>('/users', {
1058
+ immediate: true,
1059
+ select: (users) => users.map(u => ({
1060
+ id: u.id,
1061
+ fullName: `${u.firstName} ${u.lastName}`,
1062
+ })),
1063
+ })
1064
+ ```
1065
+
1066
+ #### Filter Results
1067
+
1068
+ ```typescript
1069
+ const { data } = useApi<Task[]>('/tasks', {
1070
+ immediate: true,
1071
+ select: (tasks) => tasks.filter(t => t.status === 'active'),
1072
+ })
1073
+ ```
1074
+
1075
+ #### select vs mutate
1076
+
1077
+ | | `select` | `mutate` |
1078
+ |---|---|---|
1079
+ | When it runs | On every successful response (auto) | When you call it manually |
1080
+ | With polling | Re-applied on every tick | Need to call in `onSuccess` each time |
1081
+ | With SWR | Re-applied on revalidation | Need to call in `onSuccess` |
1082
+ | `onSuccess` receives | Raw `AxiosResponse<TRaw>` | โ€” |
1083
+
1084
+ > [!NOTE]
1085
+ > `onSuccess` always receives the **raw** `AxiosResponse` from the server, not the selected value.
1086
+ > This lets you access headers, status, and the original shape if needed.
1087
+
1088
+ > [!NOTE]
1089
+ > The cache always stores the **raw** server response, not the selected value.
1090
+ > `select` is re-applied each time data is read from cache โ€” including SWR cache hits.
1091
+ > If you change your `select` function, the next cache hit will re-apply the new transformation.
1092
+
1093
+ ---
1094
+
951
1095
  ## ๐Ÿ“Š Real-World Examples
952
1096
 
953
1097
  ### Data Table with Pagination & Sorting
@@ -1352,6 +1496,38 @@ const api = createApiClient({
1352
1496
 
1353
1497
  ---
1354
1498
 
1499
+ ### withCredentials โ€” Per-Request Cookie Control
1500
+
1501
+ **TL;DR: Override the Axios instance default for a single request without changing global settings.**
1502
+
1503
+ `withCredentials` controls whether cookies and other credentials are included in cross-origin requests (CORS). Set it globally in `createApiClient` and override it per request when needed.
1504
+
1505
+ ```typescript
1506
+ // Global: withCredentials: false (Bearer token auth, no cookies)
1507
+ const api = createApiClient({ baseURL: '/api' })
1508
+
1509
+ // Override: this specific endpoint needs cookies
1510
+ const { data } = useApi('/user/session', {
1511
+ withCredentials: true,
1512
+ immediate: true,
1513
+ })
1514
+ ```
1515
+
1516
+ ```typescript
1517
+ // Global: withCredentials: true (full cookie-based auth)
1518
+ const api = createApiClient({ baseURL: '/api', withCredentials: true })
1519
+
1520
+ // Override: skip cookies for a public CDN request
1521
+ const { data } = useApi('https://cdn.example.com/config.json', {
1522
+ withCredentials: false,
1523
+ immediate: true,
1524
+ })
1525
+ ```
1526
+
1527
+ Omitting `withCredentials` in `useApi` options means the Axios instance default is used โ€” no override applied.
1528
+
1529
+ ---
1530
+
1355
1531
  ### Saving Tokens After Login
1356
1532
 
1357
1533
  ```typescript
@@ -1660,14 +1836,22 @@ function useMyCustomComposable<T>(fetchFn: () => Promise<T>) {
1660
1836
 
1661
1837
  ## ๐Ÿ“š API Reference
1662
1838
 
1663
- ### `useApi<T, D>(url, options)`
1839
+ ### `useApi<TRaw, D, TSelected>(url, options)`
1840
+
1841
+ Three type parameters โ€” all optional with defaults:
1842
+
1843
+ | Parameter | Default | Description |
1844
+ |-----------|---------|-------------|
1845
+ | `TRaw` | `unknown` | Shape of the raw response data from the server |
1846
+ | `D` | `unknown` | Shape of the request body / params |
1847
+ | `TSelected` | `TRaw` | Shape of `data.value` after `select` is applied. Equals `TRaw` when `select` is not used |
1664
1848
 
1665
1849
  **Arguments:**
1666
1850
 
1667
1851
  | Argument | Type | Description |
1668
1852
  |----------|------|-------------|
1669
1853
  | `url` | `MaybeRefOrGetter<string \| undefined>` | API endpoint. String, ref, or getter function. Returning `undefined` prevents the request. |
1670
- | `options` | `UseApiOptions<T, D>` | Configuration object (see below). |
1854
+ | `options` | `UseApiOptions<TRaw, D, TSelected>` | Configuration object (see below). |
1671
1855
 
1672
1856
  ---
1673
1857
 
@@ -1697,6 +1881,7 @@ function useMyCustomComposable<T>(fetchFn: () => Promise<T>) {
1697
1881
  |--------|------|---------|-------------|
1698
1882
  | `cache` | `string \| CacheOptions` | `undefined` | Cache the response in memory. String shorthand uses default 5-min TTL. See [Response Caching](#response-caching) |
1699
1883
  | `invalidateCache` | `string \| string[]` | `undefined` | Cache key(s) to delete on 2xx success. Never fires on error |
1884
+ | `staleWhileRevalidate` | `boolean` | `false` | When `true` and a cache hit occurs, return stale data immediately and revalidate in the background. `revalidating` is `true` during the background fetch. See [SWR](#stale-while-revalidate-swr) |
1700
1885
 
1701
1886
  **Polling:**
1702
1887
 
@@ -1712,11 +1897,17 @@ function useMyCustomComposable<T>(fetchFn: () => Promise<T>) {
1712
1897
  | `retryDelay` | `number` | `1000` | How many milliseconds to wait between retry attempts |
1713
1898
  | `retryStatusCodes` | `number[]` | `[408,429,500,502,503,504]` | HTTP status codes that trigger a retry. `[]` means retry on any error |
1714
1899
 
1900
+ **Data Transformation:**
1901
+
1902
+ | Option | Type | Default | Description |
1903
+ |--------|------|---------|-------------|
1904
+ | `select` | `(data: TRaw) => TSelected` | `undefined` | Transform response data before it is stored in `data`. Re-applied on every fetch, polling tick, and SWR revalidation. Cache always stores raw data. See [select](#select--declarative-data-transformation) |
1905
+
1715
1906
  **State Initialization:**
1716
1907
 
1717
1908
  | Option | Type | Default | Description |
1718
1909
  |--------|------|---------|-------------|
1719
- | `initialData` | `T` | `null` | Initial value for `data` before the first request completes |
1910
+ | `initialData` | `TSelected` | `null` | Initial value for `data` before the first request completes |
1720
1911
  | `initialLoading` | `boolean` | `false` | Initial value for `loading` โ€” set `true` to show a spinner before the first request fires |
1721
1912
 
1722
1913
  **Lifecycle Hooks:**
@@ -1734,6 +1925,12 @@ function useMyCustomComposable<T>(fetchFn: () => Promise<T>) {
1734
1925
  |--------|------|---------|-------------|
1735
1926
  | `skipErrorNotification` | `boolean` | `false` | When `true`, the global `onError` handler is NOT called for this request |
1736
1927
 
1928
+ **Credentials:**
1929
+
1930
+ | Option | Type | Default | Description |
1931
+ |--------|------|---------|-------------|
1932
+ | `withCredentials` | `boolean` | `undefined` | Override the Axios instance default for this request only. `true` = send cookies/credentials, `false` = omit them. Omitting uses the instance default set in `createApiClient` |
1933
+
1737
1934
  **Advanced:**
1738
1935
 
1739
1936
  | Option | Type | Default | Description |
@@ -1746,13 +1943,14 @@ function useMyCustomComposable<T>(fetchFn: () => Promise<T>) {
1746
1943
 
1747
1944
  | Name | Type | Description |
1748
1945
  |------|------|-------------|
1749
- | `data` | `Ref<T \| null>` | Response data from the last successful request |
1946
+ | `data` | `Ref<TSelected \| null>` | Response data from the last successful request (transformed by `select` if provided) |
1750
1947
  | `loading` | `Ref<boolean>` | `true` while a request is in flight (including retry delays) |
1751
1948
  | `error` | `Ref<ApiError \| null>` | Error from the last failed request; `null` on success |
1752
1949
  | `statusCode` | `Ref<number \| null>` | HTTP status code from the last completed request |
1753
- | `response` | `Ref<AxiosResponse<T> \| null>` | Full Axios response object including headers |
1754
- | `execute(config?)` | `(config?: ApiRequestConfig<D>) => Promise<T \| null>` | Manually trigger the request, optionally overriding options |
1755
- | `mutate(newData)` | `(newData: T \| null \| ((prev: T \| null) => T \| null)) => void` | Update `data` locally without a network request; clears `error` |
1950
+ | `response` | `Ref<AxiosResponse<unknown> \| null>` | Full Axios response object including headers (raw, before `select`) |
1951
+ | `revalidating` | `Ref<boolean>` | `true` while a background SWR revalidation is in flight. Always `false` when `staleWhileRevalidate` is not set |
1952
+ | `execute(config?)` | `(config?: ApiRequestConfig<D>) => Promise<TSelected \| null>` | Manually trigger the request, optionally overriding options |
1953
+ | `mutate(newData)` | `(newData: TSelected \| null \| ((prev: TSelected \| null) => TSelected \| null)) => void` | Update `data` locally without a network request; clears `error` |
1756
1954
  | `abort(msg?)` | `(message?: string) => void` | Cancel the current in-flight request |
1757
1955
  | `reset()` | `() => void` | Cancel the request and reset all state to initial values |
1758
1956
  | `ignoreUpdates(fn)` | `(updater: () => void) => void` | Run `updater` without triggering watch-based re-execution |
package/dist/index.cjs CHANGED
@@ -285,11 +285,20 @@ function useApi(url, options = {}) {
285
285
  useGlobalAbort = globalOptions?.useGlobalAbort ?? true,
286
286
  initialLoading = false,
287
287
  poll = 0,
288
+ // Explicitly excluded from axiosConfig โ€” these are useApi-only options
289
+ // and must not be forwarded to axios.request()
290
+ cache: _cache,
291
+ invalidateCache: _invalidateCache,
292
+ watch: _watch,
293
+ staleWhileRevalidate = false,
294
+ select,
288
295
  ...axiosConfig
289
296
  } = options;
290
297
  const maxRetries = retry === false ? 0 : retry === true ? 3 : retry;
298
+ const applySelect = (raw) => select ? select(raw) : raw;
291
299
  const startLoading = initialLoading ?? immediate;
292
300
  const state = useApiState(initialData, { initialLoading: startLoading });
301
+ const revalidating = (0, import_vue4.ref)(false);
293
302
  const abortController2 = (0, import_vue4.ref)(null);
294
303
  const globalAbort = useGlobalAbort ? useAbortController() : null;
295
304
  let pollTimer = null;
@@ -306,11 +315,16 @@ function useApi(url, options = {}) {
306
315
  };
307
316
  const executeRequest = async (config) => {
308
317
  const cacheOpts = normalizeCacheOptions(options.cache);
318
+ let isRevalidating = false;
309
319
  if (cacheOpts) {
310
320
  const cached = readCache(cacheOpts.id);
311
321
  if (cached !== null) {
312
- state.mutate(cached);
313
- return cached;
322
+ state.mutate(applySelect(cached));
323
+ if (!staleWhileRevalidate) {
324
+ return applySelect(cached);
325
+ }
326
+ isRevalidating = true;
327
+ revalidating.value = true;
314
328
  }
315
329
  }
316
330
  if (pollTimer) clearTimeout(pollTimer);
@@ -324,15 +338,16 @@ function useApi(url, options = {}) {
324
338
  const gs = globalAbort.getSignal();
325
339
  if (!gs.aborted) {
326
340
  subscribedSignal = gs;
327
- const currentCount = globalAbort.abortCount.value;
328
341
  globalAbortHandler = () => {
329
- if (globalAbort.abortCount.value === currentCount) controller.abort("Global filter change");
342
+ controller.abort("Cancelled by global abort");
330
343
  };
331
344
  gs.addEventListener("abort", globalAbortHandler);
332
345
  }
333
346
  }
334
- onBefore?.();
335
- state.setLoading(true);
347
+ if (!isRevalidating) {
348
+ onBefore?.();
349
+ state.setLoading(true);
350
+ }
336
351
  state.setError(null);
337
352
  let wasCancelled = false;
338
353
  let retryCount = 0;
@@ -356,7 +371,8 @@ function useApi(url, options = {}) {
356
371
  signal: controller.signal,
357
372
  ...{ authMode: config?.authMode || authMode }
358
373
  });
359
- state.mutate(response.data, response);
374
+ const selected = applySelect(response.data);
375
+ state.mutate(selected, response);
360
376
  state.setStatusCode(response.status);
361
377
  if (cacheOpts) {
362
378
  writeCache(cacheOpts.id, response.data, cacheOpts.staleTime);
@@ -365,7 +381,7 @@ function useApi(url, options = {}) {
365
381
  invalidateCache(options.invalidateCache);
366
382
  }
367
383
  onSuccess?.(response);
368
- return response.data;
384
+ return selected;
369
385
  } catch (err) {
370
386
  if (controller.signal.aborted || (0, import_axios2.isAxiosError)(err) && err.code === "ERR_CANCELED") {
371
387
  wasCancelled = true;
@@ -407,8 +423,9 @@ function useApi(url, options = {}) {
407
423
  return null;
408
424
  } finally {
409
425
  if (globalAbortHandler && subscribedSignal) subscribedSignal.removeEventListener("abort", globalAbortHandler);
426
+ revalidating.value = false;
410
427
  if (!wasCancelled) {
411
- state.setLoading(false);
428
+ if (!isRevalidating) state.setLoading(false);
412
429
  onFinish?.();
413
430
  const { interval, whenHidden } = getPollConfig();
414
431
  if (interval > 0) {
@@ -492,8 +509,10 @@ function useApi(url, options = {}) {
492
509
  }
493
510
  }, { deep: true });
494
511
  }
495
- return { ...state, execute, abort, reset, ignoreUpdates };
512
+ return { ...state, revalidating, execute, abort, reset, ignoreUpdates };
496
513
  }
514
+
515
+ // src/useApi.helpers.ts
497
516
  function useApiGet(url, options) {
498
517
  return useApi(url, { ...options, method: "GET" });
499
518
  }
package/dist/index.d.cts CHANGED
@@ -37,18 +37,97 @@ interface ApiRequestConfig<D = unknown> extends Omit<AxiosRequestConfig<D>, "dat
37
37
  * Empty array = retry on any error (network errors included).
38
38
  */
39
39
  retryStatusCodes?: number[];
40
+ /**
41
+ * Include credentials (cookies, Authorization headers) in cross-origin requests.
42
+ *
43
+ * Supports three auth strategies:
44
+ *
45
+ * **1. Bearer token (default)** โ€” tokens in localStorage, no cookies needed:
46
+ * ```ts
47
+ * createApiClient({ baseURL: '/api' }) // withCredentials: false by default
48
+ * ```
49
+ *
50
+ * **2. Full cookie-based auth** โ€” server sets httpOnly cookies for both tokens:
51
+ * ```ts
52
+ * createApiClient({ baseURL: '/api', withCredentials: true })
53
+ * ```
54
+ *
55
+ * **3. Hybrid** โ€” Bearer access token + httpOnly refresh cookie:
56
+ * ```ts
57
+ * createApiClient({
58
+ * baseURL: '/api',
59
+ * authOptions: { refreshWithCredentials: true } // cookies only on /auth/refresh
60
+ * })
61
+ * ```
62
+ *
63
+ * **Per-request override** โ€” override the global setting for a specific request:
64
+ * ```ts
65
+ * // Global: withCredentials: false, but this endpoint needs cookies
66
+ * const { data } = useApi('/user/profile', { withCredentials: true })
67
+ *
68
+ * // Global: withCredentials: true, but skip cookies for public CDN
69
+ * const { data } = useApi('https://cdn.example.com/config.json', {
70
+ * withCredentials: false,
71
+ * immediate: true
72
+ * })
73
+ * ```
74
+ */
75
+ withCredentials?: boolean;
40
76
  }
41
- interface UseApiOptions<T = unknown, D = unknown> extends ApiRequestConfig<D> {
77
+ interface UseApiOptions<T = unknown, D = unknown, TSelected = T> extends ApiRequestConfig<D> {
42
78
  immediate?: boolean;
43
79
  onSuccess?: (response: AxiosResponse<T>) => void;
44
80
  onError?: (error: ApiError) => void;
45
81
  onBefore?: () => void;
46
82
  onFinish?: () => void;
47
- initialData?: T;
83
+ /**
84
+ * Transform the raw response data before it is stored in `data`.
85
+ * Applied on every successful response โ€” including polling, SWR revalidation,
86
+ * and watch-triggered re-fetches. The cache always stores the raw server data;
87
+ * `select` is re-applied each time data is read from cache.
88
+ *
89
+ * The second generic parameter of `useApi` becomes the output type of `select`.
90
+ *
91
+ * @example
92
+ * ```ts
93
+ * // Extract nested field
94
+ * const { data } = useApi<ApiResponse, User[]>('/users', {
95
+ * select: (res) => res.data,
96
+ * })
97
+ *
98
+ * // Transform items
99
+ * const { data } = useApi<RawUser[], User[]>('/users', {
100
+ * select: (users) => users.map(u => ({ ...u, fullName: `${u.first} ${u.last}` })),
101
+ * })
102
+ * ```
103
+ */
104
+ select?: (data: T) => TSelected;
105
+ initialData?: TSelected;
48
106
  debounce?: number;
49
107
  useGlobalAbort?: boolean;
50
108
  initialLoading?: boolean;
51
109
  watch?: WatchSource | WatchSource[];
110
+ /**
111
+ * Return cached data immediately and revalidate in the background.
112
+ *
113
+ * Requires the `cache` option to be set. On a cache hit the cached value
114
+ * is returned right away (no loading state, no spinner) while a fresh
115
+ * request runs silently. The `revalidating` ref is `true` during the
116
+ * background fetch so you can show a subtle indicator if needed.
117
+ *
118
+ * On a cache miss the request behaves normally (loading: true).
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * const { data, revalidating } = useApi('/users', {
123
+ * cache: 'users',
124
+ * staleWhileRevalidate: true,
125
+ * immediate: true,
126
+ * })
127
+ * // Template: <span v-if="revalidating">โ†ป</span>
128
+ * ```
129
+ */
130
+ staleWhileRevalidate?: boolean;
52
131
  /**
53
132
  * Polling configuration.
54
133
  * - Pass a **number** (ms) for simple polling.
@@ -81,7 +160,13 @@ interface UseApiReturn<T = unknown, D = unknown> {
81
160
  loading: Ref<boolean>;
82
161
  error: Ref<ApiError | null>;
83
162
  statusCode: Ref<number | null>;
84
- response: Ref<AxiosResponse<T> | null>;
163
+ response: Ref<AxiosResponse<unknown> | null>;
164
+ /**
165
+ * `true` while a background revalidation request is in-flight.
166
+ * Only active when `staleWhileRevalidate: true` and a cache hit occurred.
167
+ * Use it to show a subtle refresh indicator without blocking the UI.
168
+ */
169
+ revalidating: Ref<boolean>;
85
170
  execute: (config?: ApiRequestConfig<D>) => Promise<T | null | undefined>;
86
171
  abort: (message?: string) => void;
87
172
  reset: () => void;
@@ -254,7 +339,8 @@ declare function createApi(options: ApiPluginOptions): {
254
339
  };
255
340
  declare function useApiConfig(): ApiPluginOptions;
256
341
 
257
- declare function useApi<T = unknown, D = unknown>(url: MaybeRefOrGetter<string | undefined>, options?: UseApiOptions<T, D>): UseApiReturn<T, D>;
342
+ declare function useApi<T = unknown, D = unknown, TSelected = T>(url: MaybeRefOrGetter<string | undefined>, options?: UseApiOptions<T, D, TSelected>): UseApiReturn<TSelected, D>;
343
+
258
344
  /**
259
345
  * Helper for GET requests
260
346
  *
@@ -263,9 +349,14 @@ declare function useApi<T = unknown, D = unknown>(url: MaybeRefOrGetter<string |
263
349
  * const { data, loading, error } = useApiGet<User[]>('/users', {
264
350
  * immediate: true
265
351
  * })
352
+ *
353
+ * // With select:
354
+ * const { data } = useApiGet<ApiResponse, User[]>('/users', {
355
+ * select: (res) => res.data,
356
+ * })
266
357
  * ```
267
358
  */
268
- declare function useApiGet<T = unknown>(url: MaybeRefOrGetter<string | undefined>, options?: Omit<UseApiOptions<T>, "method">): UseApiReturn<T>;
359
+ declare function useApiGet<T = unknown, TSelected = T>(url: MaybeRefOrGetter<string | undefined>, options?: Omit<UseApiOptions<T, unknown, TSelected>, "method">): UseApiReturn<TSelected>;
269
360
  /**
270
361
  * Helper for POST requests
271
362
  *
@@ -275,7 +366,7 @@ declare function useApiGet<T = unknown>(url: MaybeRefOrGetter<string | undefined
275
366
  * await execute({ data: { name: 'John' } })
276
367
  * ```
277
368
  */
278
- declare function useApiPost<T = unknown, D = unknown>(url: MaybeRefOrGetter<string | undefined>, options?: Omit<UseApiOptions<T, D>, "method">): UseApiReturn<T, D>;
369
+ declare function useApiPost<T = unknown, D = unknown, TSelected = T>(url: MaybeRefOrGetter<string | undefined>, options?: Omit<UseApiOptions<T, D, TSelected>, "method">): UseApiReturn<TSelected, D>;
279
370
  /**
280
371
  * Helper for PUT requests
281
372
  *
@@ -285,7 +376,7 @@ declare function useApiPost<T = unknown, D = unknown>(url: MaybeRefOrGetter<stri
285
376
  * await execute({ data: { name: 'John Doe' } })
286
377
  * ```
287
378
  */
288
- declare function useApiPut<T = unknown, D = unknown>(url: MaybeRefOrGetter<string | undefined>, options?: Omit<UseApiOptions<T, D>, "method">): UseApiReturn<T, D>;
379
+ declare function useApiPut<T = unknown, D = unknown, TSelected = T>(url: MaybeRefOrGetter<string | undefined>, options?: Omit<UseApiOptions<T, D, TSelected>, "method">): UseApiReturn<TSelected, D>;
289
380
  /**
290
381
  * Helper for PATCH requests
291
382
  *
@@ -295,7 +386,7 @@ declare function useApiPut<T = unknown, D = unknown>(url: MaybeRefOrGetter<strin
295
386
  * await execute({ data: { name: 'John' } })
296
387
  * ```
297
388
  */
298
- declare function useApiPatch<T = unknown, D = unknown>(url: MaybeRefOrGetter<string | undefined>, options?: Omit<UseApiOptions<T, D>, "method">): UseApiReturn<T, D>;
389
+ declare function useApiPatch<T = unknown, D = unknown, TSelected = T>(url: MaybeRefOrGetter<string | undefined>, options?: Omit<UseApiOptions<T, D, TSelected>, "method">): UseApiReturn<TSelected, D>;
299
390
  /**
300
391
  * Helper for DELETE requests
301
392
  *
@@ -305,7 +396,7 @@ declare function useApiPatch<T = unknown, D = unknown>(url: MaybeRefOrGetter<str
305
396
  * await execute()
306
397
  * ```
307
398
  */
308
- declare function useApiDelete<T = unknown>(url: MaybeRefOrGetter<string | undefined>, options?: Omit<UseApiOptions<T>, "method">): UseApiReturn<T>;
399
+ declare function useApiDelete<T = unknown, TSelected = T>(url: MaybeRefOrGetter<string | undefined>, options?: Omit<UseApiOptions<T, unknown, TSelected>, "method">): UseApiReturn<TSelected>;
309
400
 
310
401
  /**
311
402
  * Execute multiple API requests in parallel with full reactive state
package/dist/index.d.ts CHANGED
@@ -37,18 +37,97 @@ interface ApiRequestConfig<D = unknown> extends Omit<AxiosRequestConfig<D>, "dat
37
37
  * Empty array = retry on any error (network errors included).
38
38
  */
39
39
  retryStatusCodes?: number[];
40
+ /**
41
+ * Include credentials (cookies, Authorization headers) in cross-origin requests.
42
+ *
43
+ * Supports three auth strategies:
44
+ *
45
+ * **1. Bearer token (default)** โ€” tokens in localStorage, no cookies needed:
46
+ * ```ts
47
+ * createApiClient({ baseURL: '/api' }) // withCredentials: false by default
48
+ * ```
49
+ *
50
+ * **2. Full cookie-based auth** โ€” server sets httpOnly cookies for both tokens:
51
+ * ```ts
52
+ * createApiClient({ baseURL: '/api', withCredentials: true })
53
+ * ```
54
+ *
55
+ * **3. Hybrid** โ€” Bearer access token + httpOnly refresh cookie:
56
+ * ```ts
57
+ * createApiClient({
58
+ * baseURL: '/api',
59
+ * authOptions: { refreshWithCredentials: true } // cookies only on /auth/refresh
60
+ * })
61
+ * ```
62
+ *
63
+ * **Per-request override** โ€” override the global setting for a specific request:
64
+ * ```ts
65
+ * // Global: withCredentials: false, but this endpoint needs cookies
66
+ * const { data } = useApi('/user/profile', { withCredentials: true })
67
+ *
68
+ * // Global: withCredentials: true, but skip cookies for public CDN
69
+ * const { data } = useApi('https://cdn.example.com/config.json', {
70
+ * withCredentials: false,
71
+ * immediate: true
72
+ * })
73
+ * ```
74
+ */
75
+ withCredentials?: boolean;
40
76
  }
41
- interface UseApiOptions<T = unknown, D = unknown> extends ApiRequestConfig<D> {
77
+ interface UseApiOptions<T = unknown, D = unknown, TSelected = T> extends ApiRequestConfig<D> {
42
78
  immediate?: boolean;
43
79
  onSuccess?: (response: AxiosResponse<T>) => void;
44
80
  onError?: (error: ApiError) => void;
45
81
  onBefore?: () => void;
46
82
  onFinish?: () => void;
47
- initialData?: T;
83
+ /**
84
+ * Transform the raw response data before it is stored in `data`.
85
+ * Applied on every successful response โ€” including polling, SWR revalidation,
86
+ * and watch-triggered re-fetches. The cache always stores the raw server data;
87
+ * `select` is re-applied each time data is read from cache.
88
+ *
89
+ * The second generic parameter of `useApi` becomes the output type of `select`.
90
+ *
91
+ * @example
92
+ * ```ts
93
+ * // Extract nested field
94
+ * const { data } = useApi<ApiResponse, User[]>('/users', {
95
+ * select: (res) => res.data,
96
+ * })
97
+ *
98
+ * // Transform items
99
+ * const { data } = useApi<RawUser[], User[]>('/users', {
100
+ * select: (users) => users.map(u => ({ ...u, fullName: `${u.first} ${u.last}` })),
101
+ * })
102
+ * ```
103
+ */
104
+ select?: (data: T) => TSelected;
105
+ initialData?: TSelected;
48
106
  debounce?: number;
49
107
  useGlobalAbort?: boolean;
50
108
  initialLoading?: boolean;
51
109
  watch?: WatchSource | WatchSource[];
110
+ /**
111
+ * Return cached data immediately and revalidate in the background.
112
+ *
113
+ * Requires the `cache` option to be set. On a cache hit the cached value
114
+ * is returned right away (no loading state, no spinner) while a fresh
115
+ * request runs silently. The `revalidating` ref is `true` during the
116
+ * background fetch so you can show a subtle indicator if needed.
117
+ *
118
+ * On a cache miss the request behaves normally (loading: true).
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * const { data, revalidating } = useApi('/users', {
123
+ * cache: 'users',
124
+ * staleWhileRevalidate: true,
125
+ * immediate: true,
126
+ * })
127
+ * // Template: <span v-if="revalidating">โ†ป</span>
128
+ * ```
129
+ */
130
+ staleWhileRevalidate?: boolean;
52
131
  /**
53
132
  * Polling configuration.
54
133
  * - Pass a **number** (ms) for simple polling.
@@ -81,7 +160,13 @@ interface UseApiReturn<T = unknown, D = unknown> {
81
160
  loading: Ref<boolean>;
82
161
  error: Ref<ApiError | null>;
83
162
  statusCode: Ref<number | null>;
84
- response: Ref<AxiosResponse<T> | null>;
163
+ response: Ref<AxiosResponse<unknown> | null>;
164
+ /**
165
+ * `true` while a background revalidation request is in-flight.
166
+ * Only active when `staleWhileRevalidate: true` and a cache hit occurred.
167
+ * Use it to show a subtle refresh indicator without blocking the UI.
168
+ */
169
+ revalidating: Ref<boolean>;
85
170
  execute: (config?: ApiRequestConfig<D>) => Promise<T | null | undefined>;
86
171
  abort: (message?: string) => void;
87
172
  reset: () => void;
@@ -254,7 +339,8 @@ declare function createApi(options: ApiPluginOptions): {
254
339
  };
255
340
  declare function useApiConfig(): ApiPluginOptions;
256
341
 
257
- declare function useApi<T = unknown, D = unknown>(url: MaybeRefOrGetter<string | undefined>, options?: UseApiOptions<T, D>): UseApiReturn<T, D>;
342
+ declare function useApi<T = unknown, D = unknown, TSelected = T>(url: MaybeRefOrGetter<string | undefined>, options?: UseApiOptions<T, D, TSelected>): UseApiReturn<TSelected, D>;
343
+
258
344
  /**
259
345
  * Helper for GET requests
260
346
  *
@@ -263,9 +349,14 @@ declare function useApi<T = unknown, D = unknown>(url: MaybeRefOrGetter<string |
263
349
  * const { data, loading, error } = useApiGet<User[]>('/users', {
264
350
  * immediate: true
265
351
  * })
352
+ *
353
+ * // With select:
354
+ * const { data } = useApiGet<ApiResponse, User[]>('/users', {
355
+ * select: (res) => res.data,
356
+ * })
266
357
  * ```
267
358
  */
268
- declare function useApiGet<T = unknown>(url: MaybeRefOrGetter<string | undefined>, options?: Omit<UseApiOptions<T>, "method">): UseApiReturn<T>;
359
+ declare function useApiGet<T = unknown, TSelected = T>(url: MaybeRefOrGetter<string | undefined>, options?: Omit<UseApiOptions<T, unknown, TSelected>, "method">): UseApiReturn<TSelected>;
269
360
  /**
270
361
  * Helper for POST requests
271
362
  *
@@ -275,7 +366,7 @@ declare function useApiGet<T = unknown>(url: MaybeRefOrGetter<string | undefined
275
366
  * await execute({ data: { name: 'John' } })
276
367
  * ```
277
368
  */
278
- declare function useApiPost<T = unknown, D = unknown>(url: MaybeRefOrGetter<string | undefined>, options?: Omit<UseApiOptions<T, D>, "method">): UseApiReturn<T, D>;
369
+ declare function useApiPost<T = unknown, D = unknown, TSelected = T>(url: MaybeRefOrGetter<string | undefined>, options?: Omit<UseApiOptions<T, D, TSelected>, "method">): UseApiReturn<TSelected, D>;
279
370
  /**
280
371
  * Helper for PUT requests
281
372
  *
@@ -285,7 +376,7 @@ declare function useApiPost<T = unknown, D = unknown>(url: MaybeRefOrGetter<stri
285
376
  * await execute({ data: { name: 'John Doe' } })
286
377
  * ```
287
378
  */
288
- declare function useApiPut<T = unknown, D = unknown>(url: MaybeRefOrGetter<string | undefined>, options?: Omit<UseApiOptions<T, D>, "method">): UseApiReturn<T, D>;
379
+ declare function useApiPut<T = unknown, D = unknown, TSelected = T>(url: MaybeRefOrGetter<string | undefined>, options?: Omit<UseApiOptions<T, D, TSelected>, "method">): UseApiReturn<TSelected, D>;
289
380
  /**
290
381
  * Helper for PATCH requests
291
382
  *
@@ -295,7 +386,7 @@ declare function useApiPut<T = unknown, D = unknown>(url: MaybeRefOrGetter<strin
295
386
  * await execute({ data: { name: 'John' } })
296
387
  * ```
297
388
  */
298
- declare function useApiPatch<T = unknown, D = unknown>(url: MaybeRefOrGetter<string | undefined>, options?: Omit<UseApiOptions<T, D>, "method">): UseApiReturn<T, D>;
389
+ declare function useApiPatch<T = unknown, D = unknown, TSelected = T>(url: MaybeRefOrGetter<string | undefined>, options?: Omit<UseApiOptions<T, D, TSelected>, "method">): UseApiReturn<TSelected, D>;
299
390
  /**
300
391
  * Helper for DELETE requests
301
392
  *
@@ -305,7 +396,7 @@ declare function useApiPatch<T = unknown, D = unknown>(url: MaybeRefOrGetter<str
305
396
  * await execute()
306
397
  * ```
307
398
  */
308
- declare function useApiDelete<T = unknown>(url: MaybeRefOrGetter<string | undefined>, options?: Omit<UseApiOptions<T>, "method">): UseApiReturn<T>;
399
+ declare function useApiDelete<T = unknown, TSelected = T>(url: MaybeRefOrGetter<string | undefined>, options?: Omit<UseApiOptions<T, unknown, TSelected>, "method">): UseApiReturn<TSelected>;
309
400
 
310
401
  /**
311
402
  * Execute multiple API requests in parallel with full reactive state
package/dist/index.mjs CHANGED
@@ -232,11 +232,20 @@ function useApi(url, options = {}) {
232
232
  useGlobalAbort = globalOptions?.useGlobalAbort ?? true,
233
233
  initialLoading = false,
234
234
  poll = 0,
235
+ // Explicitly excluded from axiosConfig โ€” these are useApi-only options
236
+ // and must not be forwarded to axios.request()
237
+ cache: _cache,
238
+ invalidateCache: _invalidateCache,
239
+ watch: _watch,
240
+ staleWhileRevalidate = false,
241
+ select,
235
242
  ...axiosConfig
236
243
  } = options;
237
244
  const maxRetries = retry === false ? 0 : retry === true ? 3 : retry;
245
+ const applySelect = (raw) => select ? select(raw) : raw;
238
246
  const startLoading = initialLoading ?? immediate;
239
247
  const state = useApiState(initialData, { initialLoading: startLoading });
248
+ const revalidating = ref3(false);
240
249
  const abortController2 = ref3(null);
241
250
  const globalAbort = useGlobalAbort ? useAbortController() : null;
242
251
  let pollTimer = null;
@@ -253,11 +262,16 @@ function useApi(url, options = {}) {
253
262
  };
254
263
  const executeRequest = async (config) => {
255
264
  const cacheOpts = normalizeCacheOptions(options.cache);
265
+ let isRevalidating = false;
256
266
  if (cacheOpts) {
257
267
  const cached = readCache(cacheOpts.id);
258
268
  if (cached !== null) {
259
- state.mutate(cached);
260
- return cached;
269
+ state.mutate(applySelect(cached));
270
+ if (!staleWhileRevalidate) {
271
+ return applySelect(cached);
272
+ }
273
+ isRevalidating = true;
274
+ revalidating.value = true;
261
275
  }
262
276
  }
263
277
  if (pollTimer) clearTimeout(pollTimer);
@@ -271,15 +285,16 @@ function useApi(url, options = {}) {
271
285
  const gs = globalAbort.getSignal();
272
286
  if (!gs.aborted) {
273
287
  subscribedSignal = gs;
274
- const currentCount = globalAbort.abortCount.value;
275
288
  globalAbortHandler = () => {
276
- if (globalAbort.abortCount.value === currentCount) controller.abort("Global filter change");
289
+ controller.abort("Cancelled by global abort");
277
290
  };
278
291
  gs.addEventListener("abort", globalAbortHandler);
279
292
  }
280
293
  }
281
- onBefore?.();
282
- state.setLoading(true);
294
+ if (!isRevalidating) {
295
+ onBefore?.();
296
+ state.setLoading(true);
297
+ }
283
298
  state.setError(null);
284
299
  let wasCancelled = false;
285
300
  let retryCount = 0;
@@ -303,7 +318,8 @@ function useApi(url, options = {}) {
303
318
  signal: controller.signal,
304
319
  ...{ authMode: config?.authMode || authMode }
305
320
  });
306
- state.mutate(response.data, response);
321
+ const selected = applySelect(response.data);
322
+ state.mutate(selected, response);
307
323
  state.setStatusCode(response.status);
308
324
  if (cacheOpts) {
309
325
  writeCache(cacheOpts.id, response.data, cacheOpts.staleTime);
@@ -312,7 +328,7 @@ function useApi(url, options = {}) {
312
328
  invalidateCache(options.invalidateCache);
313
329
  }
314
330
  onSuccess?.(response);
315
- return response.data;
331
+ return selected;
316
332
  } catch (err) {
317
333
  if (controller.signal.aborted || isAxiosError2(err) && err.code === "ERR_CANCELED") {
318
334
  wasCancelled = true;
@@ -354,8 +370,9 @@ function useApi(url, options = {}) {
354
370
  return null;
355
371
  } finally {
356
372
  if (globalAbortHandler && subscribedSignal) subscribedSignal.removeEventListener("abort", globalAbortHandler);
373
+ revalidating.value = false;
357
374
  if (!wasCancelled) {
358
- state.setLoading(false);
375
+ if (!isRevalidating) state.setLoading(false);
359
376
  onFinish?.();
360
377
  const { interval, whenHidden } = getPollConfig();
361
378
  if (interval > 0) {
@@ -439,8 +456,10 @@ function useApi(url, options = {}) {
439
456
  }
440
457
  }, { deep: true });
441
458
  }
442
- return { ...state, execute, abort, reset, ignoreUpdates };
459
+ return { ...state, revalidating, execute, abort, reset, ignoreUpdates };
443
460
  }
461
+
462
+ // src/useApi.helpers.ts
444
463
  function useApiGet(url, options) {
445
464
  return useApi(url, { ...options, method: "GET" });
446
465
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ametie/vue-muza-use",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "Powerful Vue 3 API composable (Muza Kit) with Axios, Auto-Refresh & TypeScript",
5
5
  "author": "MortyQ",
6
6
  "license": "MIT",