@ametie/vue-muza-use 1.0.0 → 1.2.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
@@ -41,6 +41,7 @@ A production-ready composable that eliminates boilerplate and solves the hard pr
41
41
  - 🔐 **JWT Token Management** — Automatic token refresh with request queueing on 401 responses
42
42
  - 🎛️ **Flexible Architecture** — Bring your own Axios instance with full configuration control
43
43
  - 🍪 **withCredentials** — Per-request cookie and cross-origin credential control
44
+ - 🔭 **DevTools Panel** — Inspect live requests, payloads, and instance state via `@ametie/vue-muza-devtools`
44
45
 
45
46
  ---
46
47
 
@@ -63,11 +64,11 @@ A production-ready composable that eliminates boilerplate and solves the hard pr
63
64
  | **Response caching** | ✅ | ❌ | ✅ | ✅ |
64
65
  | **TypeScript** | ✅ | ✅ | ✅ | ✅ |
65
66
  | **SSR / Nuxt** | ❌ | ✅ | ✅ | ✅ |
66
- | **DevTools** | | ❌ | ✅ | ❌ |
67
+ | **DevTools** | | ❌ | ✅ | ❌ |
67
68
 
68
69
  **Choose vue-muza-use if:** you build Vue 3 SPAs with Axios, need JWT token refresh out of the box, and want reactive request management without a heavyweight server-state solution.
69
70
 
70
- **Choose TanStack Query if:** you need SSR, DevTools, or advanced server-state normalization.
71
+ **Choose TanStack Query if:** you need SSR or advanced server-state normalization.
71
72
 
72
73
  **Choose @vueuse/useFetch if:** you want a minimal fetch wrapper with no opinions.
73
74
 
@@ -97,9 +98,12 @@ A production-ready composable that eliminates boilerplate and solves the hard pr
97
98
  - [Data Table with Pagination](#data-table-with-pagination--sorting)
98
99
  - [Request Cancellation](#request-cancellation)
99
100
  - [Batch Requests](#batch-requests)
101
+ - [Reactive Requests (Auto-tracking)](#reactive-requests--auto-tracking)
102
+ - [Batch Polling](#batch-polling)
100
103
 
101
104
  **Advanced:**
102
105
  - [Advanced Configuration](#️-advanced-configuration)
106
+ - [DevTools Panel](#-devtools-panel)
103
107
  - [Authentication & Token Management](#-authentication--token-management)
104
108
  - [Error Handling Reference](#-error-handling-reference)
105
109
  - [Utilities & Standalone Composables](#-utilities--standalone-composables)
@@ -798,13 +802,15 @@ const { data } = useApi('/messages', {
798
802
  Apply to all `useApi` instances at once:
799
803
 
800
804
  ```typescript
801
- createApiClient({
802
- axios,
805
+ const api = createApiClient({ baseURL: 'https://api.example.com' })
806
+
807
+ app.use(createApi({
808
+ axios: api,
803
809
  globalOptions: {
804
810
  refetchOnFocus: true,
805
811
  refetchOnReconnect: true,
806
812
  },
807
- })
813
+ }))
808
814
  ```
809
815
 
810
816
  Opt individual requests out with `refetchOnFocus: false`:
@@ -1403,6 +1409,77 @@ const { loading, progress, execute } = useApiBatch(urls, {
1403
1409
  </template>
1404
1410
  ```
1405
1411
 
1412
+ #### Reactive Requests — Auto-tracking
1413
+
1414
+ **TL;DR: Pass a getter function as `requests` — the batch re-executes automatically when the getter's reactive dependencies change.**
1415
+
1416
+ This mirrors `useApi`'s auto-tracking behavior. No explicit `watch` option needed.
1417
+
1418
+ ```typescript
1419
+ import { ref } from 'vue'
1420
+ import { useApiBatch } from '@ametie/vue-muza-use'
1421
+
1422
+ interface User { id: number; name: string }
1423
+
1424
+ const pages = ref([1, 2, 3])
1425
+
1426
+ // Getter is auto-tracked — re-executes when pages.value changes
1427
+ const { successfulData, loading } = useApiBatch<User>(
1428
+ () => pages.value.map(page => ({ url: '/users', params: { page } }))
1429
+ )
1430
+
1431
+ pages.value = [4, 5, 6] // → new batch fires automatically
1432
+ ```
1433
+
1434
+ Set `lazy: true` to disable auto-tracking and keep full manual control:
1435
+
1436
+ ```typescript
1437
+ const { execute } = useApiBatch(
1438
+ () => ids.value.map(id => `/items/${id}`),
1439
+ { lazy: true } // reactive changes to ids do NOT trigger re-execution
1440
+ )
1441
+
1442
+ // You control when it runs
1443
+ await execute()
1444
+ ```
1445
+
1446
+ #### Batch Polling
1447
+
1448
+ **TL;DR: Re-execute the batch on a fixed interval — useful for dashboards and status monitoring.**
1449
+
1450
+ Same semantics as `useApi`'s `poll` option.
1451
+
1452
+ ```typescript
1453
+ import { useApiBatch } from '@ametie/vue-muza-use'
1454
+
1455
+ const { data, loading } = useApiBatch(
1456
+ ['/stats/cpu', '/stats/memory', '/stats/disk'],
1457
+ {
1458
+ immediate: true,
1459
+ poll: 5000 // re-execute every 5 seconds
1460
+ }
1461
+ )
1462
+ ```
1463
+
1464
+ With `whenHidden` control:
1465
+
1466
+ ```typescript
1467
+ import { ref } from 'vue'
1468
+ import { useApiBatch } from '@ametie/vue-muza-use'
1469
+
1470
+ const interval = ref(10000)
1471
+
1472
+ const { data, abort } = useApiBatch(
1473
+ ['/queue/jobs', '/queue/workers'],
1474
+ {
1475
+ immediate: true,
1476
+ poll: { interval, whenHidden: false } // pauses when tab is hidden
1477
+ }
1478
+ )
1479
+
1480
+ interval.value = 0 // stop polling
1481
+ ```
1482
+
1406
1483
  ---
1407
1484
 
1408
1485
  ## ⚙️ Advanced Configuration
@@ -1494,6 +1571,44 @@ createApp(App).use(createApi({
1494
1571
 
1495
1572
  ---
1496
1573
 
1574
+ ## 🔭 DevTools Panel
1575
+
1576
+ **TL;DR: Enable it in the plugin and get a live network inspector in your browser. No extra packages to install.**
1577
+
1578
+ The devtools panel is included with `@ametie/vue-muza-use`. It loads on demand and has zero impact on production bundles when disabled.
1579
+
1580
+ ### Setup
1581
+
1582
+ Pass `devtools` to `createApi`. Gate it on `NODE_ENV` to keep production builds clean:
1583
+
1584
+ ```typescript
1585
+ import { createApp } from 'vue'
1586
+ import { createApi, createApiClient } from '@ametie/vue-muza-use'
1587
+ import App from './App.vue'
1588
+
1589
+ const api = createApiClient({ baseURL: 'https://api.example.com' })
1590
+
1591
+ createApp(App).use(createApi({
1592
+ axios: api,
1593
+ devtools: {
1594
+ enabled: process.env.NODE_ENV !== 'production'
1595
+ }
1596
+ }))
1597
+ ```
1598
+
1599
+ The panel loads asynchronously — it has zero impact on startup time.
1600
+
1601
+ ### DevTools Options
1602
+
1603
+ | Option | Type | Default | Description |
1604
+ |--------|------|---------|-------------|
1605
+ | `enabled` | `boolean` | — | Required. Set `true` to mount the panel |
1606
+ | `maxHistory` | `number` | `300` | Maximum number of requests kept in the Network tab history |
1607
+ | `maxPayloadSize` | `number` | `200_000` | Maximum bytes per payload/response before truncation in the viewer |
1608
+ | `tabs` | `DevtoolsTab[]` | `[]` | Additional custom tabs to register in the panel |
1609
+
1610
+ ---
1611
+
1497
1612
  ## 🔐 Authentication & Token Management
1498
1613
 
1499
1614
  > **Note:** Authentication setup is optional. Only add this if your API requires JWT tokens.
@@ -2139,15 +2254,17 @@ type BatchInput = string | BatchRequestConfig
2139
2254
 
2140
2255
  | Option | Type | Default | Description |
2141
2256
  |--------|------|---------|-------------|
2142
- | `settled` | `boolean` | `true` | When `true`, all requests run even if some fail. When `false`, the first error stops the batch |
2257
+ | `settled` | `boolean` | `true` | When `true`, all requests run even if some fail. When `false`, the first error aborts remaining requests |
2143
2258
  | `concurrency` | `number` | unlimited | Maximum number of requests that run in parallel at once |
2144
2259
  | `immediate` | `boolean` | `false` | Execute the batch automatically when the composable is created |
2260
+ | `lazy` | `boolean` | `false` | Disable auto-tracking. When `false`, a getter passed as `requests` re-executes the batch automatically when its reactive deps change. Set `true` for full manual control via `execute()` |
2261
+ | `poll` | `number \| { interval: MaybeRefOrGetter<number>, whenHidden?: MaybeRefOrGetter<boolean> }` | `0` | Polling interval in ms. After each execution, schedules the next one after `interval` ms. `0` disables polling. `whenHidden: false` (default) pauses when the tab is hidden |
2145
2262
  | `skipErrorNotification` | `boolean` | `true` | Suppress global error handler for individual item failures |
2146
- | `watch` | `WatchSource \| WatchSource[]` | `undefined` | Re-execute the batch when these sources change |
2263
+ | `watch` | `WatchSource \| WatchSource[]` | `undefined` | **Deprecated** use a reactive getter with `lazy: false` instead. Will be removed in v2.0 |
2147
2264
  | `onItemSuccess` | `(item: BatchResultItem<T>, index: number) => void` | `undefined` | Called each time a single request in the batch succeeds |
2148
2265
  | `onItemError` | `(item: BatchResultItem<T>, index: number) => void` | `undefined` | Called each time a single request in the batch fails |
2149
2266
  | `onProgress` | `(progress: BatchProgress) => void` | `undefined` | Called after each request completes with updated progress |
2150
- | `onFinish` | `(results: BatchResultItem<T>[]) => void` | `undefined` | Called once when all requests have completed |
2267
+ | `onFinish` | `(results: BatchResultItem<T>[]) => void` | `undefined` | Called once when all requests have completed (even on `settled: false` rejection) |
2151
2268
 
2152
2269
  **UseApiBatchReturn:**
2153
2270
 
package/dist/index.cjs CHANGED
@@ -53,6 +53,48 @@ module.exports = __toCommonJS(index_exports);
53
53
 
54
54
  // src/plugin.ts
55
55
  var import_vue = require("vue");
56
+
57
+ // src/devtools.ts
58
+ var bridge = null;
59
+ var requestCounter = 0;
60
+ var pendingCalls = [];
61
+ function nextRequestId() {
62
+ return `req_${++requestCounter}`;
63
+ }
64
+ async function initDevtools(options, app) {
65
+ if (!options.enabled) return;
66
+ try {
67
+ const { createBridge } = await import("@ametie/vue-muza-devtools");
68
+ bridge = createBridge(options, app);
69
+ for (const fn of pendingCalls) fn();
70
+ pendingCalls.length = 0;
71
+ } catch {
72
+ console.warn("[vue-muza-use] devtools enabled but @ametie/vue-muza-devtools is not installed");
73
+ }
74
+ }
75
+ var devtoolsBridge = {
76
+ onInstanceCreated(id, url, options) {
77
+ if (bridge) {
78
+ bridge.onInstanceCreated(id, url, options);
79
+ } else {
80
+ pendingCalls.push(() => bridge?.onInstanceCreated(id, url, options));
81
+ }
82
+ },
83
+ onInstanceDestroyed(id) {
84
+ bridge?.onInstanceDestroyed(id);
85
+ },
86
+ onStateUpdate(id, state) {
87
+ bridge?.onStateUpdate(id, state);
88
+ },
89
+ onRequestStart(record) {
90
+ bridge?.onRequestStart(record);
91
+ },
92
+ onRequestEnd(id, result) {
93
+ bridge?.onRequestEnd(id, result);
94
+ }
95
+ };
96
+
97
+ // src/plugin.ts
56
98
  var API_INJECTION_KEY = /* @__PURE__ */ Symbol("use-api-config");
57
99
  var globalConfig = null;
58
100
  function createApi(options) {
@@ -60,6 +102,9 @@ function createApi(options) {
60
102
  return {
61
103
  install(app) {
62
104
  app.provide(API_INJECTION_KEY, options);
105
+ if (options.devtools) {
106
+ void initDevtools(options.devtools, app);
107
+ }
63
108
  }
64
109
  };
65
110
  }
@@ -110,6 +155,18 @@ function debounceFn(fn, delay) {
110
155
  };
111
156
  }
112
157
 
158
+ // src/utils/urlUtils.ts
159
+ function parseUrlQueryParams(url) {
160
+ if (!url) return null;
161
+ const qIndex = url.indexOf("?");
162
+ if (qIndex === -1) return null;
163
+ const params = {};
164
+ for (const [k, v] of new URLSearchParams(url.slice(qIndex + 1))) {
165
+ params[k] = v;
166
+ }
167
+ return Object.keys(params).length > 0 ? params : null;
168
+ }
169
+
113
170
  // src/useApi.ts
114
171
  var import_axios2 = require("axios");
115
172
  var import_vue5 = require("vue");
@@ -339,6 +396,30 @@ function useApi(url, options = {}) {
339
396
  const startLoading = initialLoading ?? immediate;
340
397
  const state = useApiState(initialData, { initialLoading: startLoading });
341
398
  const revalidating = (0, import_vue5.ref)(false);
399
+ const instanceId = (0, import_vue5.getCurrentInstance)() != null ? (0, import_vue5.useId)() : nextRequestId();
400
+ devtoolsBridge.onInstanceCreated(instanceId, (0, import_vue5.toValue)(url), {
401
+ authMode: options.authMode ?? "default",
402
+ cache: options.cache,
403
+ retry: options.retry ?? false,
404
+ poll: (() => {
405
+ const v = (0, import_vue5.toValue)(options.poll);
406
+ return typeof v === "number" ? v : 0;
407
+ })(),
408
+ immediate: options.immediate ?? false,
409
+ lazy: options.lazy ?? false
410
+ });
411
+ if ((0, import_vue5.getCurrentScope)()) {
412
+ (0, import_vue5.watch)(
413
+ () => ({
414
+ loading: state.loading.value,
415
+ error: state.error.value,
416
+ statusCode: state.statusCode.value,
417
+ data: state.data.value
418
+ }),
419
+ (s) => devtoolsBridge.onStateUpdate(instanceId, s),
420
+ { deep: true }
421
+ );
422
+ }
342
423
  const abortController2 = (0, import_vue5.ref)(null);
343
424
  const globalAbort = useGlobalAbort ? useAbortController() : null;
344
425
  let pollTimer = null;
@@ -374,6 +455,16 @@ function useApi(url, options = {}) {
374
455
  if (abortController2.value) abortController2.value.abort("Cancelled by new request");
375
456
  const controller = new AbortController();
376
457
  abortController2.value = controller;
458
+ if (config?.signal) {
459
+ const signal = config.signal;
460
+ if (signal.aborted) {
461
+ controller.abort(signal.reason);
462
+ } else {
463
+ signal.addEventListener("abort", () => {
464
+ controller.abort(signal.reason);
465
+ }, { once: true });
466
+ }
467
+ }
377
468
  let globalAbortHandler = null;
378
469
  let subscribedSignal = null;
379
470
  if (globalAbort) {
@@ -393,6 +484,9 @@ function useApi(url, options = {}) {
393
484
  state.setError(null);
394
485
  let wasCancelled = false;
395
486
  let retryCount = 0;
487
+ let devtoolsRequestId = null;
488
+ let devtoolsRequestStartedAt = 0;
489
+ let devtoolsRequestEndResult = null;
396
490
  try {
397
491
  if (!requestUrl) {
398
492
  throw new Error("Request URL is missing");
@@ -401,6 +495,21 @@ function useApi(url, options = {}) {
401
495
  const resolvedData = (0, import_vue5.toValue)(rawData);
402
496
  const rawParams = config?.params !== void 0 ? config.params : axiosConfig.params;
403
497
  const resolvedParams = (0, import_vue5.toValue)(rawParams);
498
+ const devtoolsQueryParams = resolvedParams ?? parseUrlQueryParams(requestUrl);
499
+ devtoolsRequestId = nextRequestId();
500
+ devtoolsRequestStartedAt = Date.now();
501
+ devtoolsBridge.onRequestStart({
502
+ id: devtoolsRequestId,
503
+ instanceId,
504
+ url: requestUrl,
505
+ method,
506
+ startedAt: devtoolsRequestStartedAt,
507
+ status: "pending",
508
+ statusCode: null,
509
+ requestHeaders: {},
510
+ payload: resolvedData ?? null,
511
+ queryParams: devtoolsQueryParams
512
+ });
404
513
  while (true) {
405
514
  try {
406
515
  const response = await axios2.request({
@@ -424,6 +533,12 @@ function useApi(url, options = {}) {
424
533
  }
425
534
  onSuccess?.(response);
426
535
  notifyFetched();
536
+ devtoolsRequestEndResult = {
537
+ status: "success",
538
+ statusCode: response.status,
539
+ response: response.data,
540
+ duration: Date.now() - devtoolsRequestStartedAt
541
+ };
427
542
  return selected;
428
543
  } catch (err) {
429
544
  if (controller.signal.aborted || (0, import_axios2.isAxiosError)(err) && err.code === "ERR_CANCELED") {
@@ -442,6 +557,12 @@ function useApi(url, options = {}) {
442
557
  }
443
558
  continue;
444
559
  }
560
+ devtoolsRequestEndResult = {
561
+ status: "error",
562
+ error: apiError,
563
+ statusCode: apiError.status ?? null,
564
+ duration: Date.now() - devtoolsRequestStartedAt
565
+ };
445
566
  if (!skipErrorNotification && globalErrorHandler) {
446
567
  globalErrorHandler(apiError, err);
447
568
  }
@@ -457,6 +578,12 @@ function useApi(url, options = {}) {
457
578
  return null;
458
579
  }
459
580
  const apiError = errorParser ? errorParser(err) : parseApiError(err);
581
+ devtoolsRequestEndResult = {
582
+ status: "error",
583
+ error: apiError,
584
+ statusCode: null,
585
+ duration: Date.now() - devtoolsRequestStartedAt
586
+ };
460
587
  if (!skipErrorNotification && globalErrorHandler) {
461
588
  globalErrorHandler(apiError, err);
462
589
  }
@@ -465,6 +592,12 @@ function useApi(url, options = {}) {
465
592
  onError?.(apiError);
466
593
  return null;
467
594
  } finally {
595
+ if (devtoolsRequestId !== null) {
596
+ devtoolsBridge.onRequestEnd(
597
+ devtoolsRequestId,
598
+ devtoolsRequestEndResult ?? { status: "aborted", duration: Date.now() - devtoolsRequestStartedAt }
599
+ );
600
+ }
468
601
  if (globalAbortHandler && subscribedSignal) subscribedSignal.removeEventListener("abort", globalAbortHandler);
469
602
  revalidating.value = false;
470
603
  if (!wasCancelled) {
@@ -541,7 +674,10 @@ function useApi(url, options = {}) {
541
674
  }
542
675
  };
543
676
  if ((0, import_vue5.getCurrentScope)()) {
544
- (0, import_vue5.onScopeDispose)(() => abort("Scope disposed"));
677
+ (0, import_vue5.onScopeDispose)(() => {
678
+ abort("Scope disposed");
679
+ devtoolsBridge.onInstanceDestroyed(instanceId);
680
+ });
545
681
  }
546
682
  if (immediate) execute();
547
683
  if (typeof document !== "undefined") {
@@ -608,6 +744,8 @@ function useApiBatch(requests, options = {}) {
608
744
  concurrency,
609
745
  immediate = false,
610
746
  skipErrorNotification = true,
747
+ lazy = false,
748
+ poll = 0,
611
749
  watch: watchSource,
612
750
  onItemSuccess,
613
751
  onItemError,
@@ -632,13 +770,21 @@ function useApiBatch(requests, options = {}) {
632
770
  );
633
771
  const abortControllers = (0, import_vue6.ref)([]);
634
772
  let isAborted = false;
635
- const updateProgress = (succeeded, failed) => {
636
- const currentRequests = getRequests();
773
+ let pollTimer = null;
774
+ const getPollConfig = () => {
775
+ const val = (0, import_vue6.toValue)(poll);
776
+ if (typeof val === "number") return { interval: val, whenHidden: false };
777
+ return {
778
+ interval: (0, import_vue6.toValue)(val.interval),
779
+ whenHidden: (0, import_vue6.toValue)(val.whenHidden) ?? false
780
+ };
781
+ };
782
+ const updateProgress = (succeeded, failed, total) => {
637
783
  const completed = succeeded + failed;
638
784
  const newProgress = {
639
785
  completed,
640
- total: currentRequests.length,
641
- percentage: currentRequests.length > 0 ? Math.round(completed / currentRequests.length * 100) : 0,
786
+ total,
787
+ percentage: total > 0 ? Math.round(completed / total * 100) : 0,
642
788
  succeeded,
643
789
  failed
644
790
  };
@@ -655,7 +801,8 @@ function useApiBatch(requests, options = {}) {
655
801
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
656
802
  ...config.headers && { headers: config.headers },
657
803
  useGlobalAbort: false,
658
- skipErrorNotification
804
+ skipErrorNotification,
805
+ lazy: true
659
806
  }));
660
807
  const { execute: execute2, error: reqError, statusCode, response } = api;
661
808
  try {
@@ -710,7 +857,7 @@ function useApiBatch(requests, options = {}) {
710
857
  scope.stop();
711
858
  }
712
859
  };
713
- const executeWithConcurrency = async (requests2, limit) => {
860
+ const executeWithConcurrency = async (requests2, limit, total) => {
714
861
  const results = new Array(requests2.length);
715
862
  let succeededCount = 0;
716
863
  let failedCount = 0;
@@ -728,8 +875,9 @@ function useApiBatch(requests, options = {}) {
728
875
  errors.value.push(result.error);
729
876
  }
730
877
  }
731
- updateProgress(succeededCount, failedCount);
878
+ updateProgress(succeededCount, failedCount, total);
732
879
  if (!settled && !result.success && result.error) {
880
+ abort("First request failed in non-settled mode");
733
881
  throw result.error;
734
882
  }
735
883
  return result;
@@ -758,7 +906,7 @@ function useApiBatch(requests, options = {}) {
758
906
  errors.value.push(result.error);
759
907
  }
760
908
  }
761
- updateProgress(succeededCount, failedCount);
909
+ updateProgress(succeededCount, failedCount, total);
762
910
  if (!settled && !result.success && result.error) {
763
911
  abort("First request failed in non-settled mode");
764
912
  throw result.error;
@@ -775,6 +923,9 @@ function useApiBatch(requests, options = {}) {
775
923
  return results;
776
924
  };
777
925
  const execute = async () => {
926
+ if (loading.value) {
927
+ abort("Replaced by new execution");
928
+ }
778
929
  const currentRequests = getRequests();
779
930
  isAborted = false;
780
931
  loading.value = true;
@@ -782,20 +933,21 @@ function useApiBatch(requests, options = {}) {
782
933
  errors.value = [];
783
934
  data.value = [];
784
935
  abortControllers.value = [];
785
- updateProgress(0, 0);
936
+ const total = currentRequests.length;
937
+ updateProgress(0, 0, total);
938
+ let finalResults = [];
786
939
  try {
787
- const results = await executeWithConcurrency(currentRequests, concurrency);
788
- data.value = results;
789
- const allFailed = results.every((r) => !r.success);
790
- if (allFailed && results.length > 0) {
940
+ finalResults = await executeWithConcurrency(currentRequests, concurrency, total);
941
+ data.value = finalResults;
942
+ const allFailed = finalResults.every((r) => !r.success);
943
+ if (allFailed && finalResults.length > 0) {
791
944
  error.value = {
792
- message: `All ${results.length} requests failed`,
945
+ message: `All ${finalResults.length} requests failed`,
793
946
  status: 0,
794
947
  code: "BATCH_ALL_FAILED"
795
948
  };
796
949
  }
797
- onFinish?.(results);
798
- return results;
950
+ return finalResults;
799
951
  } catch (err) {
800
952
  if (!settled) {
801
953
  error.value = err;
@@ -804,10 +956,27 @@ function useApiBatch(requests, options = {}) {
804
956
  } finally {
805
957
  loading.value = false;
806
958
  abortControllers.value = [];
959
+ onFinish?.(finalResults);
960
+ if (!isAborted) {
961
+ const { interval, whenHidden } = getPollConfig();
962
+ if (interval > 0) {
963
+ const hidden = typeof document !== "undefined" && document.hidden;
964
+ if (whenHidden || !hidden) {
965
+ pollTimer = setTimeout(() => {
966
+ pollTimer = null;
967
+ execute();
968
+ }, interval);
969
+ }
970
+ }
971
+ }
807
972
  }
808
973
  };
809
974
  const abort = (message = "Batch aborted") => {
810
975
  isAborted = true;
976
+ if (pollTimer) {
977
+ clearTimeout(pollTimer);
978
+ pollTimer = null;
979
+ }
811
980
  for (const controller of abortControllers.value) {
812
981
  controller.abort(message);
813
982
  }
@@ -830,14 +999,26 @@ function useApiBatch(requests, options = {}) {
830
999
  if ((0, import_vue6.getCurrentScope)()) {
831
1000
  (0, import_vue6.onScopeDispose)(() => abort("Scope disposed"));
832
1001
  }
1002
+ if (!lazy && typeof requests === "function") {
1003
+ const trackingScope = (0, import_vue6.effectScope)();
1004
+ trackingScope.run(() => {
1005
+ const requestsComputed = (0, import_vue6.computed)(
1006
+ () => requests().map(normalizeRequest)
1007
+ );
1008
+ (0, import_vue6.watch)(requestsComputed, () => {
1009
+ execute();
1010
+ }, { deep: true });
1011
+ });
1012
+ if ((0, import_vue6.getCurrentScope)()) (0, import_vue6.onScopeDispose)(() => trackingScope.stop());
1013
+ execute();
1014
+ } else if (immediate) {
1015
+ execute();
1016
+ }
833
1017
  if (watchSource) {
834
1018
  (0, import_vue6.watch)(watchSource, () => {
835
1019
  execute();
836
1020
  }, { deep: true });
837
1021
  }
838
- if (immediate) {
839
- execute();
840
- }
841
1022
  return {
842
1023
  data,
843
1024
  successfulData,
package/dist/index.d.cts CHANGED
@@ -139,7 +139,7 @@ interface UseApiOptions<T = unknown, D = unknown, TSelected = T> extends ApiRequ
139
139
  * Compatible with `lazy: true` — focus is a browser trigger, not a reactive dep.
140
140
  * Compatible with `poll` — both register separate listeners; `!loading` guard prevents duplicates.
141
141
  *
142
- * Can be set globally via `createApiClient({ globalOptions: { refetchOnFocus: true } })`.
142
+ * Can be set globally via `createApi({ axios, globalOptions: { refetchOnFocus: true } })`.
143
143
  * Per-request value takes precedence over global (including `false` to opt-out).
144
144
  */
145
145
  refetchOnFocus?: boolean | {
@@ -151,7 +151,7 @@ interface UseApiOptions<T = unknown, D = unknown, TSelected = T> extends ApiRequ
151
151
  * No throttle is applied — reconnect is already a rare event.
152
152
  * No refetch fires if a request is already in-flight (`loading: true`).
153
153
  *
154
- * Can be set globally via `createApiClient({ globalOptions: { refetchOnReconnect: true } })`.
154
+ * Can be set globally via `createApi({ axios, globalOptions: { refetchOnReconnect: true } })`.
155
155
  * Per-request value takes precedence over global (including `false` to opt-out).
156
156
  */
157
157
  refetchOnReconnect?: boolean;
@@ -244,6 +244,8 @@ interface ApiPluginOptions {
244
244
  * Useful if your backend has a different error structure.
245
245
  */
246
246
  errorParser?: (error: unknown) => ApiError;
247
+ /** Devtools panel configuration. Panel is disabled by default. */
248
+ devtools?: DevtoolsOptions;
247
249
  globalOptions?: {
248
250
  retry?: number | boolean;
249
251
  retryDelay?: number;
@@ -336,7 +338,34 @@ interface UseApiBatchOptions<T = unknown, D = unknown> extends Omit<ApiRequestCo
336
338
  immediate?: boolean;
337
339
  /** Skip individual error notifications */
338
340
  skipErrorNotification?: boolean;
339
- /** Watch sources to trigger re-execution */
341
+ /**
342
+ * Disable auto-tracking. When true, reactive changes to the `requests` getter
343
+ * will NOT trigger re-execution. Use when you want full manual control via execute().
344
+ * Default: false — auto-tracks when `requests` is a function.
345
+ */
346
+ lazy?: boolean;
347
+ /**
348
+ * Polling interval in ms, or advanced config object.
349
+ * - Pass a number: `poll: 5000` — re-execute every 5 seconds.
350
+ * - Pass an object: `poll: { interval: 5000, whenHidden: false }` — skip polling when tab is hidden.
351
+ * Properties inside the object can also be Refs.
352
+ */
353
+ poll?: MaybeRefOrGetter<number | {
354
+ interval: MaybeRefOrGetter<number>;
355
+ whenHidden?: MaybeRefOrGetter<boolean>;
356
+ }>;
357
+ /**
358
+ * @deprecated Use a reactive getter for `requests` with `lazy: false` (default).
359
+ * Auto-tracking will re-execute when the getter's dependencies change.
360
+ * Will be removed in v2.0.
361
+ *
362
+ * @example
363
+ * // Before (deprecated):
364
+ * useApiBatch(() => ids.value.map(id => `/items/${id}`), { watch: ids })
365
+ *
366
+ * // After (preferred):
367
+ * useApiBatch(() => ids.value.map(id => `/items/${id}`))
368
+ */
340
369
  watch?: WatchSource | WatchSource[];
341
370
  /** Callback when a single request succeeds */
342
371
  onItemSuccess?: (item: BatchResultItem<T>, index: number) => void;
@@ -370,6 +399,92 @@ interface UseApiBatchReturn<T = unknown> {
370
399
  /** Reset state to initial */
371
400
  reset: () => void;
372
401
  }
402
+ /** Lifecycle status of an HTTP request tracked by devtools. */
403
+ type RequestStatus = "pending" | "success" | "error" | "aborted";
404
+ /** Current reactive state of a useApi instance as seen by devtools. */
405
+ interface DevtoolsInstanceState {
406
+ loading: boolean;
407
+ error: ApiError | null;
408
+ statusCode: number | null;
409
+ data: unknown;
410
+ }
411
+ /** Configuration options of a useApi instance as seen by devtools. */
412
+ interface DevtoolsInstanceOptions {
413
+ authMode: AuthMode;
414
+ cache: CacheOptions | string | undefined;
415
+ retry: boolean | number;
416
+ poll: number;
417
+ immediate: boolean;
418
+ lazy: boolean;
419
+ }
420
+ /** An outgoing HTTP request record sent to devtools on request start. */
421
+ interface DevtoolsRequestRecord {
422
+ id: string;
423
+ instanceId: string | null;
424
+ url: string;
425
+ method: string;
426
+ startedAt: number;
427
+ status: RequestStatus;
428
+ statusCode: null;
429
+ requestHeaders: Record<string, string>;
430
+ payload: unknown;
431
+ queryParams: unknown;
432
+ }
433
+ /** Result of a completed HTTP request, sent to devtools on request end. */
434
+ type RequestEndResult = {
435
+ status: "success";
436
+ statusCode: number;
437
+ response: unknown;
438
+ duration: number;
439
+ } | {
440
+ status: "error";
441
+ error: ApiError;
442
+ statusCode: number | null;
443
+ duration: number;
444
+ } | {
445
+ status: "aborted";
446
+ duration: number;
447
+ };
448
+ /** Event callbacks implemented by the devtools panel, called by useApi instrumentation. */
449
+ interface DevtoolsBridge {
450
+ /** Fired when a useApi instance is created. */
451
+ onInstanceCreated: (id: string, url: string | undefined, options: DevtoolsInstanceOptions) => void;
452
+ /** Fired when a useApi instance is destroyed (scope disposed). */
453
+ onInstanceDestroyed: (id: string) => void;
454
+ /** Fired when instance state (loading, error, statusCode, data) changes. */
455
+ onStateUpdate: (id: string, state: Partial<DevtoolsInstanceState>) => void;
456
+ /** Fired when an HTTP request starts. */
457
+ onRequestStart: (record: DevtoolsRequestRecord) => void;
458
+ /** Fired when an HTTP request completes (success, error, or abort). */
459
+ onRequestEnd: (id: string, result: RequestEndResult) => void;
460
+ }
461
+ /**
462
+ * Options for the `@ametie/vue-muza-devtools` panel.
463
+ *
464
+ * @example
465
+ * ```ts
466
+ * app.use(createApi({
467
+ * axios: apiClient,
468
+ * devtools: { enabled: process.env.NODE_ENV !== 'production' },
469
+ * }))
470
+ * ```
471
+ */
472
+ interface DevtoolsOptions {
473
+ /** Enable the devtools panel. Default: false. */
474
+ enabled: boolean;
475
+ /** Maximum number of network requests kept in history. Default: 300. */
476
+ maxHistory?: number;
477
+ /** Maximum payload/response size in bytes before truncation. Default: 200_000. */
478
+ maxPayloadSize?: number;
479
+ /** Custom tabs appended after built-in tabs. */
480
+ tabs?: Array<{
481
+ id: string;
482
+ label: string;
483
+ component: unknown;
484
+ icon?: unknown;
485
+ order?: number;
486
+ }>;
487
+ }
373
488
 
374
489
  declare function createApi(options: ApiPluginOptions): {
375
490
  install(app: App): void;
@@ -448,7 +563,7 @@ declare function useApiDelete<T = unknown, TSelected = T>(url: MaybeRefOrGetter<
448
563
  * - Abort support for all pending requests
449
564
  * - Detailed per-request results with URL mapping
450
565
  * - Progress tracking
451
- * - Watch option for auto re-execution
566
+ * - Auto-tracking for reactive getter requests (lazy: false default)
452
567
  *
453
568
  * @example
454
569
  * ```ts
@@ -466,11 +581,10 @@ declare function useApiDelete<T = unknown, TSelected = T>(url: MaybeRefOrGetter<
466
581
  * const ids = [1, 2, 3]
467
582
  * useApiBatch(ids.map(id => ({ url: `/users/${id}`, method: 'DELETE' })))
468
583
  *
469
- * // Reactive getter with object configs
584
+ * // Reactive getter auto-tracks deps, re-executes when pages changes
470
585
  * const pages = ref([1, 2, 3])
471
586
  * const { successfulData } = useApiBatch(
472
- * () => pages.value.map(page => ({ url: '/users', params: { page } })),
473
- * { watch: pages, immediate: true }
587
+ * () => pages.value.map(page => ({ url: '/users', params: { page } }))
474
588
  * )
475
589
  * ```
476
590
  */
@@ -756,4 +870,4 @@ declare function invalidateCache(id: string | string[]): void;
756
870
  */
757
871
  declare function clearAllCache(): void;
758
872
 
759
- export { type ApiError, type ApiPluginOptions, type ApiRequestConfig, type ApiState, type AuthEventPayload, AuthEventType, type AuthMode, type AuthMonitorFn, type AuthTokens$1 as AuthTokens, type BatchProgress, type BatchRequestConfig, type BatchResultItem, type CacheOptions, type SetDataInput, type UseApiBatchOptions, type UseApiBatchReturn, type UseApiOptions, type UseApiReturn, clearAllCache, createApi, createApiClient, invalidateCache, setAuthMonitor, setupInterceptors, tokenManager, useAbortController, useApi, useApiBatch, useApiConfig, useApiDelete, useApiGet, useApiPatch, useApiPost, useApiPut, useApiState };
873
+ export { type ApiError, type ApiPluginOptions, type ApiRequestConfig, type ApiState, type AuthEventPayload, AuthEventType, type AuthMode, type AuthMonitorFn, type AuthTokens$1 as AuthTokens, type BatchProgress, type BatchRequestConfig, type BatchResultItem, type CacheOptions, type DevtoolsBridge, type DevtoolsInstanceOptions, type DevtoolsInstanceState, type DevtoolsOptions, type DevtoolsRequestRecord, type RequestEndResult, type RequestStatus, type SetDataInput, type UseApiBatchOptions, type UseApiBatchReturn, type UseApiOptions, type UseApiReturn, clearAllCache, createApi, createApiClient, invalidateCache, setAuthMonitor, setupInterceptors, tokenManager, useAbortController, useApi, useApiBatch, useApiConfig, useApiDelete, useApiGet, useApiPatch, useApiPost, useApiPut, useApiState };
package/dist/index.d.ts CHANGED
@@ -139,7 +139,7 @@ interface UseApiOptions<T = unknown, D = unknown, TSelected = T> extends ApiRequ
139
139
  * Compatible with `lazy: true` — focus is a browser trigger, not a reactive dep.
140
140
  * Compatible with `poll` — both register separate listeners; `!loading` guard prevents duplicates.
141
141
  *
142
- * Can be set globally via `createApiClient({ globalOptions: { refetchOnFocus: true } })`.
142
+ * Can be set globally via `createApi({ axios, globalOptions: { refetchOnFocus: true } })`.
143
143
  * Per-request value takes precedence over global (including `false` to opt-out).
144
144
  */
145
145
  refetchOnFocus?: boolean | {
@@ -151,7 +151,7 @@ interface UseApiOptions<T = unknown, D = unknown, TSelected = T> extends ApiRequ
151
151
  * No throttle is applied — reconnect is already a rare event.
152
152
  * No refetch fires if a request is already in-flight (`loading: true`).
153
153
  *
154
- * Can be set globally via `createApiClient({ globalOptions: { refetchOnReconnect: true } })`.
154
+ * Can be set globally via `createApi({ axios, globalOptions: { refetchOnReconnect: true } })`.
155
155
  * Per-request value takes precedence over global (including `false` to opt-out).
156
156
  */
157
157
  refetchOnReconnect?: boolean;
@@ -244,6 +244,8 @@ interface ApiPluginOptions {
244
244
  * Useful if your backend has a different error structure.
245
245
  */
246
246
  errorParser?: (error: unknown) => ApiError;
247
+ /** Devtools panel configuration. Panel is disabled by default. */
248
+ devtools?: DevtoolsOptions;
247
249
  globalOptions?: {
248
250
  retry?: number | boolean;
249
251
  retryDelay?: number;
@@ -336,7 +338,34 @@ interface UseApiBatchOptions<T = unknown, D = unknown> extends Omit<ApiRequestCo
336
338
  immediate?: boolean;
337
339
  /** Skip individual error notifications */
338
340
  skipErrorNotification?: boolean;
339
- /** Watch sources to trigger re-execution */
341
+ /**
342
+ * Disable auto-tracking. When true, reactive changes to the `requests` getter
343
+ * will NOT trigger re-execution. Use when you want full manual control via execute().
344
+ * Default: false — auto-tracks when `requests` is a function.
345
+ */
346
+ lazy?: boolean;
347
+ /**
348
+ * Polling interval in ms, or advanced config object.
349
+ * - Pass a number: `poll: 5000` — re-execute every 5 seconds.
350
+ * - Pass an object: `poll: { interval: 5000, whenHidden: false }` — skip polling when tab is hidden.
351
+ * Properties inside the object can also be Refs.
352
+ */
353
+ poll?: MaybeRefOrGetter<number | {
354
+ interval: MaybeRefOrGetter<number>;
355
+ whenHidden?: MaybeRefOrGetter<boolean>;
356
+ }>;
357
+ /**
358
+ * @deprecated Use a reactive getter for `requests` with `lazy: false` (default).
359
+ * Auto-tracking will re-execute when the getter's dependencies change.
360
+ * Will be removed in v2.0.
361
+ *
362
+ * @example
363
+ * // Before (deprecated):
364
+ * useApiBatch(() => ids.value.map(id => `/items/${id}`), { watch: ids })
365
+ *
366
+ * // After (preferred):
367
+ * useApiBatch(() => ids.value.map(id => `/items/${id}`))
368
+ */
340
369
  watch?: WatchSource | WatchSource[];
341
370
  /** Callback when a single request succeeds */
342
371
  onItemSuccess?: (item: BatchResultItem<T>, index: number) => void;
@@ -370,6 +399,92 @@ interface UseApiBatchReturn<T = unknown> {
370
399
  /** Reset state to initial */
371
400
  reset: () => void;
372
401
  }
402
+ /** Lifecycle status of an HTTP request tracked by devtools. */
403
+ type RequestStatus = "pending" | "success" | "error" | "aborted";
404
+ /** Current reactive state of a useApi instance as seen by devtools. */
405
+ interface DevtoolsInstanceState {
406
+ loading: boolean;
407
+ error: ApiError | null;
408
+ statusCode: number | null;
409
+ data: unknown;
410
+ }
411
+ /** Configuration options of a useApi instance as seen by devtools. */
412
+ interface DevtoolsInstanceOptions {
413
+ authMode: AuthMode;
414
+ cache: CacheOptions | string | undefined;
415
+ retry: boolean | number;
416
+ poll: number;
417
+ immediate: boolean;
418
+ lazy: boolean;
419
+ }
420
+ /** An outgoing HTTP request record sent to devtools on request start. */
421
+ interface DevtoolsRequestRecord {
422
+ id: string;
423
+ instanceId: string | null;
424
+ url: string;
425
+ method: string;
426
+ startedAt: number;
427
+ status: RequestStatus;
428
+ statusCode: null;
429
+ requestHeaders: Record<string, string>;
430
+ payload: unknown;
431
+ queryParams: unknown;
432
+ }
433
+ /** Result of a completed HTTP request, sent to devtools on request end. */
434
+ type RequestEndResult = {
435
+ status: "success";
436
+ statusCode: number;
437
+ response: unknown;
438
+ duration: number;
439
+ } | {
440
+ status: "error";
441
+ error: ApiError;
442
+ statusCode: number | null;
443
+ duration: number;
444
+ } | {
445
+ status: "aborted";
446
+ duration: number;
447
+ };
448
+ /** Event callbacks implemented by the devtools panel, called by useApi instrumentation. */
449
+ interface DevtoolsBridge {
450
+ /** Fired when a useApi instance is created. */
451
+ onInstanceCreated: (id: string, url: string | undefined, options: DevtoolsInstanceOptions) => void;
452
+ /** Fired when a useApi instance is destroyed (scope disposed). */
453
+ onInstanceDestroyed: (id: string) => void;
454
+ /** Fired when instance state (loading, error, statusCode, data) changes. */
455
+ onStateUpdate: (id: string, state: Partial<DevtoolsInstanceState>) => void;
456
+ /** Fired when an HTTP request starts. */
457
+ onRequestStart: (record: DevtoolsRequestRecord) => void;
458
+ /** Fired when an HTTP request completes (success, error, or abort). */
459
+ onRequestEnd: (id: string, result: RequestEndResult) => void;
460
+ }
461
+ /**
462
+ * Options for the `@ametie/vue-muza-devtools` panel.
463
+ *
464
+ * @example
465
+ * ```ts
466
+ * app.use(createApi({
467
+ * axios: apiClient,
468
+ * devtools: { enabled: process.env.NODE_ENV !== 'production' },
469
+ * }))
470
+ * ```
471
+ */
472
+ interface DevtoolsOptions {
473
+ /** Enable the devtools panel. Default: false. */
474
+ enabled: boolean;
475
+ /** Maximum number of network requests kept in history. Default: 300. */
476
+ maxHistory?: number;
477
+ /** Maximum payload/response size in bytes before truncation. Default: 200_000. */
478
+ maxPayloadSize?: number;
479
+ /** Custom tabs appended after built-in tabs. */
480
+ tabs?: Array<{
481
+ id: string;
482
+ label: string;
483
+ component: unknown;
484
+ icon?: unknown;
485
+ order?: number;
486
+ }>;
487
+ }
373
488
 
374
489
  declare function createApi(options: ApiPluginOptions): {
375
490
  install(app: App): void;
@@ -448,7 +563,7 @@ declare function useApiDelete<T = unknown, TSelected = T>(url: MaybeRefOrGetter<
448
563
  * - Abort support for all pending requests
449
564
  * - Detailed per-request results with URL mapping
450
565
  * - Progress tracking
451
- * - Watch option for auto re-execution
566
+ * - Auto-tracking for reactive getter requests (lazy: false default)
452
567
  *
453
568
  * @example
454
569
  * ```ts
@@ -466,11 +581,10 @@ declare function useApiDelete<T = unknown, TSelected = T>(url: MaybeRefOrGetter<
466
581
  * const ids = [1, 2, 3]
467
582
  * useApiBatch(ids.map(id => ({ url: `/users/${id}`, method: 'DELETE' })))
468
583
  *
469
- * // Reactive getter with object configs
584
+ * // Reactive getter auto-tracks deps, re-executes when pages changes
470
585
  * const pages = ref([1, 2, 3])
471
586
  * const { successfulData } = useApiBatch(
472
- * () => pages.value.map(page => ({ url: '/users', params: { page } })),
473
- * { watch: pages, immediate: true }
587
+ * () => pages.value.map(page => ({ url: '/users', params: { page } }))
474
588
  * )
475
589
  * ```
476
590
  */
@@ -756,4 +870,4 @@ declare function invalidateCache(id: string | string[]): void;
756
870
  */
757
871
  declare function clearAllCache(): void;
758
872
 
759
- export { type ApiError, type ApiPluginOptions, type ApiRequestConfig, type ApiState, type AuthEventPayload, AuthEventType, type AuthMode, type AuthMonitorFn, type AuthTokens$1 as AuthTokens, type BatchProgress, type BatchRequestConfig, type BatchResultItem, type CacheOptions, type SetDataInput, type UseApiBatchOptions, type UseApiBatchReturn, type UseApiOptions, type UseApiReturn, clearAllCache, createApi, createApiClient, invalidateCache, setAuthMonitor, setupInterceptors, tokenManager, useAbortController, useApi, useApiBatch, useApiConfig, useApiDelete, useApiGet, useApiPatch, useApiPost, useApiPut, useApiState };
873
+ export { type ApiError, type ApiPluginOptions, type ApiRequestConfig, type ApiState, type AuthEventPayload, AuthEventType, type AuthMode, type AuthMonitorFn, type AuthTokens$1 as AuthTokens, type BatchProgress, type BatchRequestConfig, type BatchResultItem, type CacheOptions, type DevtoolsBridge, type DevtoolsInstanceOptions, type DevtoolsInstanceState, type DevtoolsOptions, type DevtoolsRequestRecord, type RequestEndResult, type RequestStatus, type SetDataInput, type UseApiBatchOptions, type UseApiBatchReturn, type UseApiOptions, type UseApiReturn, clearAllCache, createApi, createApiClient, invalidateCache, setAuthMonitor, setupInterceptors, tokenManager, useAbortController, useApi, useApiBatch, useApiConfig, useApiDelete, useApiGet, useApiPatch, useApiPost, useApiPut, useApiState };
package/dist/index.mjs CHANGED
@@ -1,5 +1,47 @@
1
1
  // src/plugin.ts
2
2
  import { inject } from "vue";
3
+
4
+ // src/devtools.ts
5
+ var bridge = null;
6
+ var requestCounter = 0;
7
+ var pendingCalls = [];
8
+ function nextRequestId() {
9
+ return `req_${++requestCounter}`;
10
+ }
11
+ async function initDevtools(options, app) {
12
+ if (!options.enabled) return;
13
+ try {
14
+ const { createBridge } = await import("@ametie/vue-muza-devtools");
15
+ bridge = createBridge(options, app);
16
+ for (const fn of pendingCalls) fn();
17
+ pendingCalls.length = 0;
18
+ } catch {
19
+ console.warn("[vue-muza-use] devtools enabled but @ametie/vue-muza-devtools is not installed");
20
+ }
21
+ }
22
+ var devtoolsBridge = {
23
+ onInstanceCreated(id, url, options) {
24
+ if (bridge) {
25
+ bridge.onInstanceCreated(id, url, options);
26
+ } else {
27
+ pendingCalls.push(() => bridge?.onInstanceCreated(id, url, options));
28
+ }
29
+ },
30
+ onInstanceDestroyed(id) {
31
+ bridge?.onInstanceDestroyed(id);
32
+ },
33
+ onStateUpdate(id, state) {
34
+ bridge?.onStateUpdate(id, state);
35
+ },
36
+ onRequestStart(record) {
37
+ bridge?.onRequestStart(record);
38
+ },
39
+ onRequestEnd(id, result) {
40
+ bridge?.onRequestEnd(id, result);
41
+ }
42
+ };
43
+
44
+ // src/plugin.ts
3
45
  var API_INJECTION_KEY = /* @__PURE__ */ Symbol("use-api-config");
4
46
  var globalConfig = null;
5
47
  function createApi(options) {
@@ -7,6 +49,9 @@ function createApi(options) {
7
49
  return {
8
50
  install(app) {
9
51
  app.provide(API_INJECTION_KEY, options);
52
+ if (options.devtools) {
53
+ void initDevtools(options.devtools, app);
54
+ }
10
55
  }
11
56
  };
12
57
  }
@@ -57,9 +102,21 @@ function debounceFn(fn, delay) {
57
102
  };
58
103
  }
59
104
 
105
+ // src/utils/urlUtils.ts
106
+ function parseUrlQueryParams(url) {
107
+ if (!url) return null;
108
+ const qIndex = url.indexOf("?");
109
+ if (qIndex === -1) return null;
110
+ const params = {};
111
+ for (const [k, v] of new URLSearchParams(url.slice(qIndex + 1))) {
112
+ params[k] = v;
113
+ }
114
+ return Object.keys(params).length > 0 ? params : null;
115
+ }
116
+
60
117
  // src/useApi.ts
61
118
  import { isAxiosError as isAxiosError2 } from "axios";
62
- import { ref as ref3, computed, effectScope, getCurrentScope as getCurrentScope2, onScopeDispose as onScopeDispose2, toValue, watch } from "vue";
119
+ import { ref as ref3, computed, effectScope, getCurrentInstance, getCurrentScope as getCurrentScope2, onScopeDispose as onScopeDispose2, toValue, watch, useId } from "vue";
63
120
 
64
121
  // src/utils/errorParser.ts
65
122
  import { isAxiosError } from "axios";
@@ -286,6 +343,30 @@ function useApi(url, options = {}) {
286
343
  const startLoading = initialLoading ?? immediate;
287
344
  const state = useApiState(initialData, { initialLoading: startLoading });
288
345
  const revalidating = ref3(false);
346
+ const instanceId = getCurrentInstance() != null ? useId() : nextRequestId();
347
+ devtoolsBridge.onInstanceCreated(instanceId, toValue(url), {
348
+ authMode: options.authMode ?? "default",
349
+ cache: options.cache,
350
+ retry: options.retry ?? false,
351
+ poll: (() => {
352
+ const v = toValue(options.poll);
353
+ return typeof v === "number" ? v : 0;
354
+ })(),
355
+ immediate: options.immediate ?? false,
356
+ lazy: options.lazy ?? false
357
+ });
358
+ if (getCurrentScope2()) {
359
+ watch(
360
+ () => ({
361
+ loading: state.loading.value,
362
+ error: state.error.value,
363
+ statusCode: state.statusCode.value,
364
+ data: state.data.value
365
+ }),
366
+ (s) => devtoolsBridge.onStateUpdate(instanceId, s),
367
+ { deep: true }
368
+ );
369
+ }
289
370
  const abortController2 = ref3(null);
290
371
  const globalAbort = useGlobalAbort ? useAbortController() : null;
291
372
  let pollTimer = null;
@@ -321,6 +402,16 @@ function useApi(url, options = {}) {
321
402
  if (abortController2.value) abortController2.value.abort("Cancelled by new request");
322
403
  const controller = new AbortController();
323
404
  abortController2.value = controller;
405
+ if (config?.signal) {
406
+ const signal = config.signal;
407
+ if (signal.aborted) {
408
+ controller.abort(signal.reason);
409
+ } else {
410
+ signal.addEventListener("abort", () => {
411
+ controller.abort(signal.reason);
412
+ }, { once: true });
413
+ }
414
+ }
324
415
  let globalAbortHandler = null;
325
416
  let subscribedSignal = null;
326
417
  if (globalAbort) {
@@ -340,6 +431,9 @@ function useApi(url, options = {}) {
340
431
  state.setError(null);
341
432
  let wasCancelled = false;
342
433
  let retryCount = 0;
434
+ let devtoolsRequestId = null;
435
+ let devtoolsRequestStartedAt = 0;
436
+ let devtoolsRequestEndResult = null;
343
437
  try {
344
438
  if (!requestUrl) {
345
439
  throw new Error("Request URL is missing");
@@ -348,6 +442,21 @@ function useApi(url, options = {}) {
348
442
  const resolvedData = toValue(rawData);
349
443
  const rawParams = config?.params !== void 0 ? config.params : axiosConfig.params;
350
444
  const resolvedParams = toValue(rawParams);
445
+ const devtoolsQueryParams = resolvedParams ?? parseUrlQueryParams(requestUrl);
446
+ devtoolsRequestId = nextRequestId();
447
+ devtoolsRequestStartedAt = Date.now();
448
+ devtoolsBridge.onRequestStart({
449
+ id: devtoolsRequestId,
450
+ instanceId,
451
+ url: requestUrl,
452
+ method,
453
+ startedAt: devtoolsRequestStartedAt,
454
+ status: "pending",
455
+ statusCode: null,
456
+ requestHeaders: {},
457
+ payload: resolvedData ?? null,
458
+ queryParams: devtoolsQueryParams
459
+ });
351
460
  while (true) {
352
461
  try {
353
462
  const response = await axios2.request({
@@ -371,6 +480,12 @@ function useApi(url, options = {}) {
371
480
  }
372
481
  onSuccess?.(response);
373
482
  notifyFetched();
483
+ devtoolsRequestEndResult = {
484
+ status: "success",
485
+ statusCode: response.status,
486
+ response: response.data,
487
+ duration: Date.now() - devtoolsRequestStartedAt
488
+ };
374
489
  return selected;
375
490
  } catch (err) {
376
491
  if (controller.signal.aborted || isAxiosError2(err) && err.code === "ERR_CANCELED") {
@@ -389,6 +504,12 @@ function useApi(url, options = {}) {
389
504
  }
390
505
  continue;
391
506
  }
507
+ devtoolsRequestEndResult = {
508
+ status: "error",
509
+ error: apiError,
510
+ statusCode: apiError.status ?? null,
511
+ duration: Date.now() - devtoolsRequestStartedAt
512
+ };
392
513
  if (!skipErrorNotification && globalErrorHandler) {
393
514
  globalErrorHandler(apiError, err);
394
515
  }
@@ -404,6 +525,12 @@ function useApi(url, options = {}) {
404
525
  return null;
405
526
  }
406
527
  const apiError = errorParser ? errorParser(err) : parseApiError(err);
528
+ devtoolsRequestEndResult = {
529
+ status: "error",
530
+ error: apiError,
531
+ statusCode: null,
532
+ duration: Date.now() - devtoolsRequestStartedAt
533
+ };
407
534
  if (!skipErrorNotification && globalErrorHandler) {
408
535
  globalErrorHandler(apiError, err);
409
536
  }
@@ -412,6 +539,12 @@ function useApi(url, options = {}) {
412
539
  onError?.(apiError);
413
540
  return null;
414
541
  } finally {
542
+ if (devtoolsRequestId !== null) {
543
+ devtoolsBridge.onRequestEnd(
544
+ devtoolsRequestId,
545
+ devtoolsRequestEndResult ?? { status: "aborted", duration: Date.now() - devtoolsRequestStartedAt }
546
+ );
547
+ }
415
548
  if (globalAbortHandler && subscribedSignal) subscribedSignal.removeEventListener("abort", globalAbortHandler);
416
549
  revalidating.value = false;
417
550
  if (!wasCancelled) {
@@ -488,7 +621,10 @@ function useApi(url, options = {}) {
488
621
  }
489
622
  };
490
623
  if (getCurrentScope2()) {
491
- onScopeDispose2(() => abort("Scope disposed"));
624
+ onScopeDispose2(() => {
625
+ abort("Scope disposed");
626
+ devtoolsBridge.onInstanceDestroyed(instanceId);
627
+ });
492
628
  }
493
629
  if (immediate) execute();
494
630
  if (typeof document !== "undefined") {
@@ -555,6 +691,8 @@ function useApiBatch(requests, options = {}) {
555
691
  concurrency,
556
692
  immediate = false,
557
693
  skipErrorNotification = true,
694
+ lazy = false,
695
+ poll = 0,
558
696
  watch: watchSource,
559
697
  onItemSuccess,
560
698
  onItemError,
@@ -579,13 +717,21 @@ function useApiBatch(requests, options = {}) {
579
717
  );
580
718
  const abortControllers = ref4([]);
581
719
  let isAborted = false;
582
- const updateProgress = (succeeded, failed) => {
583
- const currentRequests = getRequests();
720
+ let pollTimer = null;
721
+ const getPollConfig = () => {
722
+ const val = toValue2(poll);
723
+ if (typeof val === "number") return { interval: val, whenHidden: false };
724
+ return {
725
+ interval: toValue2(val.interval),
726
+ whenHidden: toValue2(val.whenHidden) ?? false
727
+ };
728
+ };
729
+ const updateProgress = (succeeded, failed, total) => {
584
730
  const completed = succeeded + failed;
585
731
  const newProgress = {
586
732
  completed,
587
- total: currentRequests.length,
588
- percentage: currentRequests.length > 0 ? Math.round(completed / currentRequests.length * 100) : 0,
733
+ total,
734
+ percentage: total > 0 ? Math.round(completed / total * 100) : 0,
589
735
  succeeded,
590
736
  failed
591
737
  };
@@ -602,7 +748,8 @@ function useApiBatch(requests, options = {}) {
602
748
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
603
749
  ...config.headers && { headers: config.headers },
604
750
  useGlobalAbort: false,
605
- skipErrorNotification
751
+ skipErrorNotification,
752
+ lazy: true
606
753
  }));
607
754
  const { execute: execute2, error: reqError, statusCode, response } = api;
608
755
  try {
@@ -657,7 +804,7 @@ function useApiBatch(requests, options = {}) {
657
804
  scope.stop();
658
805
  }
659
806
  };
660
- const executeWithConcurrency = async (requests2, limit) => {
807
+ const executeWithConcurrency = async (requests2, limit, total) => {
661
808
  const results = new Array(requests2.length);
662
809
  let succeededCount = 0;
663
810
  let failedCount = 0;
@@ -675,8 +822,9 @@ function useApiBatch(requests, options = {}) {
675
822
  errors.value.push(result.error);
676
823
  }
677
824
  }
678
- updateProgress(succeededCount, failedCount);
825
+ updateProgress(succeededCount, failedCount, total);
679
826
  if (!settled && !result.success && result.error) {
827
+ abort("First request failed in non-settled mode");
680
828
  throw result.error;
681
829
  }
682
830
  return result;
@@ -705,7 +853,7 @@ function useApiBatch(requests, options = {}) {
705
853
  errors.value.push(result.error);
706
854
  }
707
855
  }
708
- updateProgress(succeededCount, failedCount);
856
+ updateProgress(succeededCount, failedCount, total);
709
857
  if (!settled && !result.success && result.error) {
710
858
  abort("First request failed in non-settled mode");
711
859
  throw result.error;
@@ -722,6 +870,9 @@ function useApiBatch(requests, options = {}) {
722
870
  return results;
723
871
  };
724
872
  const execute = async () => {
873
+ if (loading.value) {
874
+ abort("Replaced by new execution");
875
+ }
725
876
  const currentRequests = getRequests();
726
877
  isAborted = false;
727
878
  loading.value = true;
@@ -729,20 +880,21 @@ function useApiBatch(requests, options = {}) {
729
880
  errors.value = [];
730
881
  data.value = [];
731
882
  abortControllers.value = [];
732
- updateProgress(0, 0);
883
+ const total = currentRequests.length;
884
+ updateProgress(0, 0, total);
885
+ let finalResults = [];
733
886
  try {
734
- const results = await executeWithConcurrency(currentRequests, concurrency);
735
- data.value = results;
736
- const allFailed = results.every((r) => !r.success);
737
- if (allFailed && results.length > 0) {
887
+ finalResults = await executeWithConcurrency(currentRequests, concurrency, total);
888
+ data.value = finalResults;
889
+ const allFailed = finalResults.every((r) => !r.success);
890
+ if (allFailed && finalResults.length > 0) {
738
891
  error.value = {
739
- message: `All ${results.length} requests failed`,
892
+ message: `All ${finalResults.length} requests failed`,
740
893
  status: 0,
741
894
  code: "BATCH_ALL_FAILED"
742
895
  };
743
896
  }
744
- onFinish?.(results);
745
- return results;
897
+ return finalResults;
746
898
  } catch (err) {
747
899
  if (!settled) {
748
900
  error.value = err;
@@ -751,10 +903,27 @@ function useApiBatch(requests, options = {}) {
751
903
  } finally {
752
904
  loading.value = false;
753
905
  abortControllers.value = [];
906
+ onFinish?.(finalResults);
907
+ if (!isAborted) {
908
+ const { interval, whenHidden } = getPollConfig();
909
+ if (interval > 0) {
910
+ const hidden = typeof document !== "undefined" && document.hidden;
911
+ if (whenHidden || !hidden) {
912
+ pollTimer = setTimeout(() => {
913
+ pollTimer = null;
914
+ execute();
915
+ }, interval);
916
+ }
917
+ }
918
+ }
754
919
  }
755
920
  };
756
921
  const abort = (message = "Batch aborted") => {
757
922
  isAborted = true;
923
+ if (pollTimer) {
924
+ clearTimeout(pollTimer);
925
+ pollTimer = null;
926
+ }
758
927
  for (const controller of abortControllers.value) {
759
928
  controller.abort(message);
760
929
  }
@@ -777,14 +946,26 @@ function useApiBatch(requests, options = {}) {
777
946
  if (getCurrentScope3()) {
778
947
  onScopeDispose3(() => abort("Scope disposed"));
779
948
  }
949
+ if (!lazy && typeof requests === "function") {
950
+ const trackingScope = effectScope2();
951
+ trackingScope.run(() => {
952
+ const requestsComputed = computed2(
953
+ () => requests().map(normalizeRequest)
954
+ );
955
+ watch2(requestsComputed, () => {
956
+ execute();
957
+ }, { deep: true });
958
+ });
959
+ if (getCurrentScope3()) onScopeDispose3(() => trackingScope.stop());
960
+ execute();
961
+ } else if (immediate) {
962
+ execute();
963
+ }
780
964
  if (watchSource) {
781
965
  watch2(watchSource, () => {
782
966
  execute();
783
967
  }, { deep: true });
784
968
  }
785
- if (immediate) {
786
- execute();
787
- }
788
969
  return {
789
970
  data,
790
971
  successfulData,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ametie/vue-muza-use",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Powerful Vue 3 API composable (Muza) with Axios, Auto-Refresh & TypeScript",
5
5
  "author": "MortyQ",
6
6
  "license": "MIT",