@swoff/cli 0.4.1 → 0.4.4
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/dist/lib/commands/generate-guide.js +15 -15
- package/dist/lib/commands/generate-guide.js.map +1 -1
- package/dist/lib/commands/init.js +52 -6
- package/dist/lib/commands/init.js.map +1 -1
- package/dist/lib/config/validator.js +1 -1
- package/dist/lib/config/validator.js.map +1 -1
- package/dist/lib/generators/file-generators/client-injector-bundle.js +2 -1
- package/dist/lib/generators/file-generators/client-injector-bundle.js.map +1 -1
- package/dist/lib/generators/file-generators/client-injector.js +2 -1
- package/dist/lib/generators/file-generators/client-injector.js.map +1 -1
- package/dist/lib/generators/file-generators/context.js +4 -1
- package/dist/lib/generators/file-generators/context.js.map +1 -1
- package/dist/lib/generators/file-generators/generate-framework-adapters.js +28 -22
- package/dist/lib/generators/file-generators/generate-framework-adapters.js.map +1 -1
- package/dist/lib/generators/sw-sections/activate-handler.js +1 -5
- package/dist/lib/generators/sw-sections/activate-handler.js.map +1 -1
- package/dist/lib/generators/sw-sections/background-precache.js +8 -8
- package/dist/lib/generators/sw-sections/batch-refresh-queue.js +2 -5
- package/dist/lib/generators/sw-sections/batch-refresh-queue.js.map +1 -1
- package/dist/lib/generators/sw-sections/fetch-handler.js +14 -3
- package/dist/lib/generators/sw-sections/fetch-handler.js.map +1 -1
- package/dist/lib/shared/config-types.js +1 -0
- package/dist/lib/shared/config-types.js.map +1 -1
- package/dist/lib/utils/detect-framework.js +16 -0
- package/dist/lib/utils/detect-framework.js.map +1 -1
- package/dist/runtime/client-injector-bundle.js +2 -2
- package/dist/runtime/client-injector-bundle.js.map +1 -1
- package/dist/runtime/client-injector.js +3 -2
- package/dist/runtime/client-injector.js.map +1 -1
- package/dist/runtime/gql-wrapper.js +3 -1
- package/dist/runtime/gql-wrapper.js.map +1 -1
- package/dist/runtime/swoff-api-bundle.js +2 -1
- package/dist/runtime/swoff-api-bundle.js.map +1 -1
- package/package.json +1 -1
- package/templates/react/{useOfflineAnalytics.jsx → useSwoffAnalytics.jsx} +3 -3
- package/templates/react/{useOfflineAnalytics.tsx → useSwoffAnalytics.tsx} +3 -3
- package/templates/react/{useAuth.jsx → useSwoffAuth.jsx} +2 -2
- package/templates/react/{useAuth.tsx → useSwoffAuth.tsx} +2 -2
- package/templates/react/{useCachedFetch.jsx → useSwoffFetch.jsx} +6 -6
- package/templates/react/{useCachedFetch.tsx → useSwoffFetch.tsx} +7 -7
- package/templates/react/{useMutation.jsx → useSwoffMutation.jsx} +3 -3
- package/templates/react/{useMutation.tsx → useSwoffMutation.tsx} +3 -3
- package/templates/react/{useMutationState.jsx → useSwoffMutationState.jsx} +1 -1
- package/templates/react/{useMutationState.tsx → useSwoffMutationState.tsx} +2 -2
- package/templates/react/{useNetworkStatus.jsx → useSwoffNetwork.jsx} +2 -2
- package/templates/react/{useNetworkStatus.tsx → useSwoffNetwork.tsx} +2 -2
- package/templates/react/{usePrecacheProgress.jsx → useSwoffPrecache.jsx} +4 -7
- package/templates/react/{usePrecacheProgress.tsx → useSwoffPrecache.tsx} +4 -7
- package/templates/react/{usePrefetch.jsx → useSwoffPrefetch.jsx} +2 -2
- package/templates/react/{usePrefetch.tsx → useSwoffPrefetch.tsx} +2 -2
- package/templates/react/{usePushSubscription.jsx → useSwoffPush.jsx} +1 -1
- package/templates/react/{usePushSubscription.tsx → useSwoffPush.tsx} +1 -1
- package/templates/react/{usePwaInstall.jsx → useSwoffPwa.jsx} +1 -1
- package/templates/react/{usePwaInstall.tsx → useSwoffPwa.tsx} +1 -1
- package/templates/react/{useMutationQueue.jsx → useSwoffQueue.jsx} +2 -2
- package/templates/react/{useMutationQueue.tsx → useSwoffQueue.tsx} +2 -2
- package/templates/react/{useStorageEstimate.jsx → useSwoffStorage.jsx} +2 -2
- package/templates/react/{useStorageEstimate.tsx → useSwoffStorage.tsx} +2 -2
- package/templates/react/{useBackgroundSync.jsx → useSwoffSync.jsx} +1 -1
- package/templates/react/{useBackgroundSync.tsx → useSwoffSync.tsx} +1 -1
- package/templates/svelte/useSwoffAnalytics.js +31 -0
- package/templates/svelte/useSwoffAnalytics.ts +37 -0
- package/templates/svelte/useSwoffAuth.js +95 -0
- package/templates/svelte/useSwoffAuth.ts +96 -0
- package/templates/svelte/useSwoffFetch.js +97 -0
- package/templates/svelte/useSwoffFetch.ts +112 -0
- package/templates/svelte/useSwoffMutation.js +110 -0
- package/templates/svelte/useSwoffMutation.ts +141 -0
- package/templates/svelte/useSwoffMutationState.js +27 -0
- package/templates/svelte/useSwoffMutationState.ts +28 -0
- package/templates/svelte/useSwoffNetwork.js +75 -0
- package/templates/svelte/useSwoffNetwork.ts +75 -0
- package/templates/svelte/useSwoffPrecache.js +19 -0
- package/templates/svelte/useSwoffPrecache.ts +19 -0
- package/templates/svelte/useSwoffPrefetch.js +19 -0
- package/templates/svelte/useSwoffPrefetch.ts +20 -0
- package/templates/svelte/useSwoffPush.js +82 -0
- package/templates/svelte/useSwoffPush.ts +82 -0
- package/templates/svelte/useSwoffPwa.js +27 -0
- package/templates/svelte/useSwoffPwa.ts +27 -0
- package/templates/svelte/useSwoffQueue.js +57 -0
- package/templates/svelte/useSwoffQueue.ts +67 -0
- package/templates/svelte/useSwoffReset.js +21 -0
- package/templates/svelte/useSwoffReset.ts +32 -0
- package/templates/svelte/useSwoffStorage.js +47 -0
- package/templates/svelte/useSwoffStorage.ts +47 -0
- package/templates/svelte/useSwoffSync.js +31 -0
- package/templates/svelte/useSwoffSync.ts +31 -0
- package/templates/vue/useSwoffAnalytics.js +46 -0
- package/templates/vue/useSwoffAnalytics.ts +54 -0
- package/templates/vue/useSwoffAuth.js +106 -0
- package/templates/vue/useSwoffAuth.ts +118 -0
- package/templates/vue/useSwoffFetch.js +127 -0
- package/templates/vue/useSwoffFetch.ts +143 -0
- package/templates/vue/useSwoffMutation.js +124 -0
- package/templates/vue/useSwoffMutation.ts +155 -0
- package/templates/vue/useSwoffMutationState.js +35 -0
- package/templates/vue/useSwoffMutationState.ts +36 -0
- package/templates/vue/useSwoffNetwork.js +86 -0
- package/templates/vue/useSwoffNetwork.ts +89 -0
- package/templates/vue/useSwoffPrecache.js +19 -0
- package/templates/vue/useSwoffPrecache.ts +19 -0
- package/templates/vue/useSwoffPrefetch.js +33 -0
- package/templates/vue/useSwoffPrefetch.ts +41 -0
- package/templates/vue/useSwoffPush.js +81 -0
- package/templates/vue/useSwoffPush.ts +81 -0
- package/templates/vue/useSwoffPwa.js +27 -0
- package/templates/vue/useSwoffPwa.ts +27 -0
- package/templates/vue/useSwoffQueue.js +70 -0
- package/templates/vue/useSwoffQueue.ts +82 -0
- package/templates/vue/useSwoffReset.js +32 -0
- package/templates/vue/useSwoffReset.ts +39 -0
- package/templates/vue/useSwoffStorage.js +56 -0
- package/templates/vue/useSwoffStorage.ts +57 -0
- package/templates/vue/useSwoffSync.js +30 -0
- package/templates/vue/useSwoffSync.ts +30 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { ref, onMounted, onUnmounted } from "vue";
|
|
2
|
+
import { getAuthState } from "../auth/state";
|
|
3
|
+
import { setAuth, clearAuth, ensureValidAuth } from "../auth/store";
|
|
4
|
+
import type { AuthData } from "../auth/adapter";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reactive auth state with actions: setAuth, clearAuth, ensureValid.
|
|
8
|
+
*
|
|
9
|
+
* Listens for sw-auth-state-change events (dispatched by clearAuth(),
|
|
10
|
+
* setAuth(), or the SW's AUTH_CLEARED broadcast).
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* const { authenticated, auth, online, isLoading, error, setAuth, clearAuth, ensureValid } = useSwoffAuth();
|
|
14
|
+
*
|
|
15
|
+
* // Login:
|
|
16
|
+
* const res = await fetch("/api/login", { method: "POST" });
|
|
17
|
+
* const data = await res.json();
|
|
18
|
+
* await setAuth({ token: data.token, user: data.user, expiresAt: data.expiresAt });
|
|
19
|
+
*
|
|
20
|
+
* // Access user data (typed via AuthData):
|
|
21
|
+
* const userName = auth?.user?.name;
|
|
22
|
+
*
|
|
23
|
+
* // Logout — clears tokens from memory and user cache from IndexedDB:
|
|
24
|
+
* await clearAuth();
|
|
25
|
+
*
|
|
26
|
+
* // Silently refresh the session (page restore):
|
|
27
|
+
* const authData = await ensureValid();
|
|
28
|
+
*
|
|
29
|
+
* @returns {{ authenticated, auth, online, isLoading, error, setAuth, clearAuth, ensureValid }}
|
|
30
|
+
*/
|
|
31
|
+
export function useSwoffAuth() {
|
|
32
|
+
const authenticated = ref(false);
|
|
33
|
+
const auth = ref<AuthData | null>(null);
|
|
34
|
+
const online = ref(typeof navigator !== "undefined" ? navigator.onLine : true);
|
|
35
|
+
const isLoading = ref(false);
|
|
36
|
+
const error = ref<Error | null>(null);
|
|
37
|
+
|
|
38
|
+
async function refreshState() {
|
|
39
|
+
try {
|
|
40
|
+
const authState = await getAuthState();
|
|
41
|
+
authenticated.value = authState.authenticated;
|
|
42
|
+
auth.value = authState.auth;
|
|
43
|
+
error.value = null;
|
|
44
|
+
} catch {
|
|
45
|
+
// ignore — auth not initialized
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
onMounted(() => {
|
|
50
|
+
refreshState();
|
|
51
|
+
|
|
52
|
+
function onOnline() { online.value = true; }
|
|
53
|
+
function onOffline() { online.value = false; }
|
|
54
|
+
function onAuthChange() { refreshState(); }
|
|
55
|
+
|
|
56
|
+
window.addEventListener("online", onOnline);
|
|
57
|
+
window.addEventListener("offline", onOffline);
|
|
58
|
+
window.addEventListener("sw-auth-state-change", onAuthChange);
|
|
59
|
+
|
|
60
|
+
onUnmounted(() => {
|
|
61
|
+
window.removeEventListener("online", onOnline);
|
|
62
|
+
window.removeEventListener("offline", onOffline);
|
|
63
|
+
window.removeEventListener("sw-auth-state-change", onAuthChange);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
async function doSetAuth(authData: AuthData) {
|
|
68
|
+
isLoading.value = true;
|
|
69
|
+
error.value = null;
|
|
70
|
+
try {
|
|
71
|
+
await setAuth(authData);
|
|
72
|
+
await refreshState();
|
|
73
|
+
} catch (err) {
|
|
74
|
+
error.value = err instanceof Error ? err : new Error(String(err));
|
|
75
|
+
} finally {
|
|
76
|
+
isLoading.value = false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function doClearAuth() {
|
|
81
|
+
isLoading.value = true;
|
|
82
|
+
error.value = null;
|
|
83
|
+
try {
|
|
84
|
+
await clearAuth();
|
|
85
|
+
await refreshState();
|
|
86
|
+
} catch (err) {
|
|
87
|
+
error.value = err instanceof Error ? err : new Error(String(err));
|
|
88
|
+
} finally {
|
|
89
|
+
isLoading.value = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function doEnsureValid(): Promise<AuthData | null> {
|
|
94
|
+
isLoading.value = true;
|
|
95
|
+
error.value = null;
|
|
96
|
+
try {
|
|
97
|
+
const result = await ensureValidAuth();
|
|
98
|
+
await refreshState();
|
|
99
|
+
return result;
|
|
100
|
+
} catch (err) {
|
|
101
|
+
error.value = err instanceof Error ? err : new Error(String(err));
|
|
102
|
+
return null;
|
|
103
|
+
} finally {
|
|
104
|
+
isLoading.value = false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
authenticated,
|
|
110
|
+
auth,
|
|
111
|
+
online,
|
|
112
|
+
isLoading,
|
|
113
|
+
error,
|
|
114
|
+
setAuth: doSetAuth,
|
|
115
|
+
clearAuth: doClearAuth,
|
|
116
|
+
ensureValid: doEnsureValid,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { ref, onMounted, onUnmounted, watch } from "vue";
|
|
2
|
+
import { fetchWithCache } from "../fetch/core.js";
|
|
3
|
+
import { generateTags } from "../cache/tags.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Fetch and cache data with automatic invalidation, revalidation, and offline support.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* const { data, error, loading, refetch } = useSwoffFetch("/api/todos");
|
|
10
|
+
*
|
|
11
|
+
* // Select/transform data
|
|
12
|
+
* const { data: names } = useSwoffFetch("/api/users", {
|
|
13
|
+
* select: (users) => users.map(u => u.name),
|
|
14
|
+
* });
|
|
15
|
+
*
|
|
16
|
+
* // Keep previous data while fetching the next page
|
|
17
|
+
* const { data } = useSwoffFetch(`/api/items?page=${page}`, {
|
|
18
|
+
* keepPreviousData: true,
|
|
19
|
+
* });
|
|
20
|
+
*/
|
|
21
|
+
export function useSwoffFetch(url, options = {}) {
|
|
22
|
+
const {
|
|
23
|
+
select,
|
|
24
|
+
retry: retryOpt,
|
|
25
|
+
placeholderData,
|
|
26
|
+
onSuccess,
|
|
27
|
+
onError,
|
|
28
|
+
...fetchOptions
|
|
29
|
+
} = options;
|
|
30
|
+
|
|
31
|
+
const data = ref(placeholderData ?? null);
|
|
32
|
+
const error = ref(null);
|
|
33
|
+
const loading = ref(url != null && options.enabled !== false);
|
|
34
|
+
const refetchCount = ref(0);
|
|
35
|
+
|
|
36
|
+
let cachedData = placeholderData ?? null;
|
|
37
|
+
|
|
38
|
+
const isEnabled = options.enabled !== false && url != null;
|
|
39
|
+
|
|
40
|
+
function refetch() {
|
|
41
|
+
refetchCount.value++;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
watch(
|
|
45
|
+
() => url,
|
|
46
|
+
() => {
|
|
47
|
+
if (isEnabled) refetch();
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
watch(
|
|
52
|
+
[() => url, () => refetchCount.value],
|
|
53
|
+
() => {
|
|
54
|
+
if (!isEnabled) return;
|
|
55
|
+
|
|
56
|
+
let cancelled = false;
|
|
57
|
+
let retriesLeft =
|
|
58
|
+
retryOpt === true
|
|
59
|
+
? Infinity
|
|
60
|
+
: typeof retryOpt === "number"
|
|
61
|
+
? retryOpt
|
|
62
|
+
: 0;
|
|
63
|
+
|
|
64
|
+
const doFetch = async () => {
|
|
65
|
+
loading.value = true;
|
|
66
|
+
try {
|
|
67
|
+
const { response } = await fetchWithCache(url, fetchOptions);
|
|
68
|
+
if (cancelled) return;
|
|
69
|
+
if (response) {
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
let errorMessage = `HTTP ${response.status}`;
|
|
72
|
+
try {
|
|
73
|
+
const errBody = await response.json();
|
|
74
|
+
errorMessage = errBody?.message || errBody?.error || errorMessage;
|
|
75
|
+
} catch {}
|
|
76
|
+
throw new Error(errorMessage);
|
|
77
|
+
}
|
|
78
|
+
const raw = await response.json();
|
|
79
|
+
const selected = select ? select(raw) : raw;
|
|
80
|
+
data.value = selected;
|
|
81
|
+
cachedData = selected;
|
|
82
|
+
if (!cancelled) onSuccess?.(selected);
|
|
83
|
+
} else {
|
|
84
|
+
data.value = null;
|
|
85
|
+
cachedData = null;
|
|
86
|
+
}
|
|
87
|
+
if (!cancelled) error.value = null;
|
|
88
|
+
} catch (err) {
|
|
89
|
+
if (!cancelled && retriesLeft > 0) {
|
|
90
|
+
retriesLeft--;
|
|
91
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
92
|
+
return doFetch();
|
|
93
|
+
}
|
|
94
|
+
if (!cancelled) {
|
|
95
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
96
|
+
error.value = e;
|
|
97
|
+
onError?.(e);
|
|
98
|
+
}
|
|
99
|
+
} finally {
|
|
100
|
+
if (!cancelled) loading.value = false;
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
doFetch();
|
|
105
|
+
},
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
if (isEnabled && url) {
|
|
109
|
+
onMounted(() => {
|
|
110
|
+
function onInvalidated(e) {
|
|
111
|
+
const detail = e.detail;
|
|
112
|
+
const tags = detail?.tags;
|
|
113
|
+
if (!tags || !Array.isArray(tags)) return;
|
|
114
|
+
const urlTags = generateTags(url);
|
|
115
|
+
if (tags.some((t) => urlTags.includes(t))) {
|
|
116
|
+
refetch();
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
window.addEventListener("cache-invalidated", onInvalidated);
|
|
120
|
+
onUnmounted(() => {
|
|
121
|
+
window.removeEventListener("cache-invalidated", onInvalidated);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { data, error, loading, refetch };
|
|
127
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { ref, onMounted, onUnmounted, watch } from "vue";
|
|
2
|
+
import { fetchWithCache } from "../fetch/core";
|
|
3
|
+
import { generateTags } from "../cache/tags";
|
|
4
|
+
import type { FetchWithCacheOptions } from "../fetch/core";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Fetch and cache data with automatic invalidation, revalidation, and offline support.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* const { data, error, loading, refetch } = useSwoffFetch("/api/todos");
|
|
11
|
+
*
|
|
12
|
+
* // Select/transform data
|
|
13
|
+
* const { data: names } = useSwoffFetch("/api/users", {
|
|
14
|
+
* select: (users) => users.map(u => u.name),
|
|
15
|
+
* });
|
|
16
|
+
*
|
|
17
|
+
* // Keep previous data while fetching the next page
|
|
18
|
+
* const { data } = useSwoffFetch(`/api/items?page=${page}`, {
|
|
19
|
+
* keepPreviousData: true,
|
|
20
|
+
* });
|
|
21
|
+
*
|
|
22
|
+
* @param url - URL to fetch. Pass null to skip fetching.
|
|
23
|
+
* @param options - Fetch options plus: select, keepPreviousData, retry, placeholderData, onSuccess, onError
|
|
24
|
+
*/
|
|
25
|
+
export function useSwoffFetch<T = unknown, R = T>(
|
|
26
|
+
url: string | null,
|
|
27
|
+
options: FetchWithCacheOptions & {
|
|
28
|
+
enabled?: boolean;
|
|
29
|
+
select?: (data: T) => R;
|
|
30
|
+
keepPreviousData?: boolean;
|
|
31
|
+
retry?: number | boolean;
|
|
32
|
+
placeholderData?: R;
|
|
33
|
+
onSuccess?: (data: R) => void;
|
|
34
|
+
onError?: (error: Error) => void;
|
|
35
|
+
} = {},
|
|
36
|
+
) {
|
|
37
|
+
const {
|
|
38
|
+
select,
|
|
39
|
+
retry: retryOpt,
|
|
40
|
+
placeholderData,
|
|
41
|
+
onSuccess,
|
|
42
|
+
onError,
|
|
43
|
+
...fetchOptions
|
|
44
|
+
} = options;
|
|
45
|
+
|
|
46
|
+
const data = ref<R | null>(placeholderData ?? null);
|
|
47
|
+
const error = ref<Error | null>(null);
|
|
48
|
+
const loading = ref(url != null && options.enabled !== false);
|
|
49
|
+
const refetchCount = ref(0);
|
|
50
|
+
|
|
51
|
+
let cachedData: R | null = placeholderData ?? null;
|
|
52
|
+
|
|
53
|
+
const isEnabled = options.enabled !== false && url != null;
|
|
54
|
+
|
|
55
|
+
function refetch() {
|
|
56
|
+
refetchCount.value++;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
watch(
|
|
60
|
+
() => url,
|
|
61
|
+
() => {
|
|
62
|
+
if (isEnabled) refetch();
|
|
63
|
+
},
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
watch(
|
|
67
|
+
[() => url, () => refetchCount.value],
|
|
68
|
+
() => {
|
|
69
|
+
if (!isEnabled) return;
|
|
70
|
+
|
|
71
|
+
let cancelled = false;
|
|
72
|
+
let retriesLeft: number =
|
|
73
|
+
retryOpt === true
|
|
74
|
+
? Infinity
|
|
75
|
+
: typeof retryOpt === "number"
|
|
76
|
+
? retryOpt
|
|
77
|
+
: 0;
|
|
78
|
+
|
|
79
|
+
const doFetch = async () => {
|
|
80
|
+
loading.value = true;
|
|
81
|
+
try {
|
|
82
|
+
const { response } = await fetchWithCache(url, fetchOptions);
|
|
83
|
+
if (cancelled) return;
|
|
84
|
+
if (response) {
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
let errorMessage = `HTTP ${response.status}`;
|
|
87
|
+
try {
|
|
88
|
+
const errBody = await response.json();
|
|
89
|
+
errorMessage = errBody?.message || errBody?.error || errorMessage;
|
|
90
|
+
} catch {}
|
|
91
|
+
throw new Error(errorMessage);
|
|
92
|
+
}
|
|
93
|
+
const raw: T = await response.json();
|
|
94
|
+
const selected = select ? select(raw) : (raw as unknown as R);
|
|
95
|
+
data.value = selected;
|
|
96
|
+
cachedData = selected;
|
|
97
|
+
if (!cancelled) onSuccess?.(selected);
|
|
98
|
+
} else {
|
|
99
|
+
data.value = null;
|
|
100
|
+
cachedData = null;
|
|
101
|
+
}
|
|
102
|
+
if (!cancelled) error.value = null;
|
|
103
|
+
} catch (err) {
|
|
104
|
+
if (!cancelled && retriesLeft > 0) {
|
|
105
|
+
retriesLeft--;
|
|
106
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
107
|
+
return doFetch();
|
|
108
|
+
}
|
|
109
|
+
if (!cancelled) {
|
|
110
|
+
const e = err instanceof Error ? err : new Error(String(err));
|
|
111
|
+
error.value = e;
|
|
112
|
+
onError?.(e);
|
|
113
|
+
}
|
|
114
|
+
} finally {
|
|
115
|
+
if (!cancelled) loading.value = false;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
doFetch();
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// Auto-refetch on cache invalidation
|
|
124
|
+
if (isEnabled && url) {
|
|
125
|
+
onMounted(() => {
|
|
126
|
+
function onInvalidated(e: Event) {
|
|
127
|
+
const detail = (e as CustomEvent).detail;
|
|
128
|
+
const tags = detail?.tags;
|
|
129
|
+
if (!tags || !Array.isArray(tags)) return;
|
|
130
|
+
const urlTags = generateTags(url!);
|
|
131
|
+
if (tags.some((t: string) => urlTags.includes(t))) {
|
|
132
|
+
refetch();
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
window.addEventListener("cache-invalidated", onInvalidated);
|
|
136
|
+
onUnmounted(() => {
|
|
137
|
+
window.removeEventListener("cache-invalidated", onInvalidated);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return { data, error, loading, refetch };
|
|
143
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { ref } from "vue";
|
|
2
|
+
import { fetchWithCache } from "../fetch/core.js";
|
|
3
|
+
import {
|
|
4
|
+
trackMutation,
|
|
5
|
+
resolveMutation,
|
|
6
|
+
rejectMutation,
|
|
7
|
+
} from "../mutation/state.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Composable for mutations (POST, PUT, PATCH, DELETE) with auto-invalidation, offline queuing,
|
|
11
|
+
* optimistic updates, and dual-level callbacks.
|
|
12
|
+
*
|
|
13
|
+
* Usage:
|
|
14
|
+
* const { mutate, isLoading, error, data } = useSwoffMutation("/api/todos", {
|
|
15
|
+
* method: "POST",
|
|
16
|
+
* onSuccess: (data) => navigate(`/item/${data.id}`),
|
|
17
|
+
* });
|
|
18
|
+
*
|
|
19
|
+
* // Check result status after mutate:
|
|
20
|
+
* const result = await mutate(body);
|
|
21
|
+
* if (result.status === "success") navigate(`/item/${result.data.id}`);
|
|
22
|
+
*/
|
|
23
|
+
export function useSwoffMutation(url, options = {}) {
|
|
24
|
+
const lastResult = ref(null);
|
|
25
|
+
const isLoading = ref(false);
|
|
26
|
+
const mutationId = ref(null);
|
|
27
|
+
|
|
28
|
+
const inFlightKeys = new Set();
|
|
29
|
+
|
|
30
|
+
async function mutate(body, callbacks) {
|
|
31
|
+
const key = options.mutationKey;
|
|
32
|
+
if (key && inFlightKeys.has(key)) return { status: "skipped" };
|
|
33
|
+
if (key) inFlightKeys.add(key);
|
|
34
|
+
|
|
35
|
+
let retriesLeft =
|
|
36
|
+
options.retry === true
|
|
37
|
+
? Infinity
|
|
38
|
+
: typeof options.retry === "number"
|
|
39
|
+
? options.retry
|
|
40
|
+
: 0;
|
|
41
|
+
|
|
42
|
+
const mid = "mut-" + crypto.randomUUID();
|
|
43
|
+
mutationId.value = mid;
|
|
44
|
+
trackMutation(mid, "pending");
|
|
45
|
+
isLoading.value = true;
|
|
46
|
+
|
|
47
|
+
options.onMutate?.();
|
|
48
|
+
|
|
49
|
+
const attempt = async () => {
|
|
50
|
+
try {
|
|
51
|
+
const {
|
|
52
|
+
mutationKey,
|
|
53
|
+
onMutate,
|
|
54
|
+
onSuccess,
|
|
55
|
+
onError,
|
|
56
|
+
onSettled,
|
|
57
|
+
retry,
|
|
58
|
+
...fetchOnlyOptions
|
|
59
|
+
} = options;
|
|
60
|
+
const fetchOptions = { ...fetchOnlyOptions, body };
|
|
61
|
+
const { response, queued } = await fetchWithCache(url, fetchOptions);
|
|
62
|
+
if (queued) {
|
|
63
|
+
resolveMutation(mid, null);
|
|
64
|
+
isLoading.value = false;
|
|
65
|
+
const r = { status: "queued" };
|
|
66
|
+
lastResult.value = r;
|
|
67
|
+
options.onSettled?.();
|
|
68
|
+
callbacks?.onSettled?.();
|
|
69
|
+
if (key) inFlightKeys.delete(key);
|
|
70
|
+
return r;
|
|
71
|
+
}
|
|
72
|
+
const data = await response.json();
|
|
73
|
+
resolveMutation(mid, data);
|
|
74
|
+
isLoading.value = false;
|
|
75
|
+
const r = { status: "success", data };
|
|
76
|
+
lastResult.value = r;
|
|
77
|
+
options.onSuccess?.(data);
|
|
78
|
+
callbacks?.onSuccess?.(data);
|
|
79
|
+
options.onSettled?.();
|
|
80
|
+
callbacks?.onSettled?.();
|
|
81
|
+
if (key) inFlightKeys.delete(key);
|
|
82
|
+
return r;
|
|
83
|
+
} catch (err) {
|
|
84
|
+
if (retriesLeft > 0) {
|
|
85
|
+
retriesLeft--;
|
|
86
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
87
|
+
return attempt();
|
|
88
|
+
}
|
|
89
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
90
|
+
rejectMutation(mid, error);
|
|
91
|
+
isLoading.value = false;
|
|
92
|
+
const r = { status: "error", error };
|
|
93
|
+
lastResult.value = r;
|
|
94
|
+
options.onError?.(error);
|
|
95
|
+
callbacks?.onError?.(error);
|
|
96
|
+
options.onSettled?.();
|
|
97
|
+
callbacks?.onSettled?.();
|
|
98
|
+
if (key) inFlightKeys.delete(key);
|
|
99
|
+
return r;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return attempt();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function reset() {
|
|
107
|
+
lastResult.value = null;
|
|
108
|
+
isLoading.value = false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
mutate,
|
|
113
|
+
isLoading,
|
|
114
|
+
isIdle: lastResult.value === null,
|
|
115
|
+
isQueued: lastResult.value?.status === "queued",
|
|
116
|
+
isSuccess: lastResult.value?.status === "success",
|
|
117
|
+
isError: lastResult.value?.status === "error",
|
|
118
|
+
data: lastResult.value?.status === "success" ? (lastResult.value.data ?? null) : null,
|
|
119
|
+
error: lastResult.value?.status === "error" ? (lastResult.value.error ?? null) : null,
|
|
120
|
+
lastResult,
|
|
121
|
+
reset,
|
|
122
|
+
mutationId,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { ref } from "vue";
|
|
2
|
+
import { fetchWithCache } from "../fetch/core";
|
|
3
|
+
import {
|
|
4
|
+
trackMutation,
|
|
5
|
+
resolveMutation,
|
|
6
|
+
rejectMutation,
|
|
7
|
+
} from "../mutation/state";
|
|
8
|
+
import type { FetchWithCacheOptions } from "../fetch/core";
|
|
9
|
+
|
|
10
|
+
export interface MutateCallbacks<TData> {
|
|
11
|
+
onSuccess?: (data: TData) => void;
|
|
12
|
+
onError?: (error: Error) => void;
|
|
13
|
+
onSettled?: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface UseMutationOptions<TData> extends FetchWithCacheOptions {
|
|
17
|
+
onMutate?: () => void;
|
|
18
|
+
onSuccess?: (data: TData) => void;
|
|
19
|
+
onError?: (error: Error) => void;
|
|
20
|
+
onSettled?: () => void;
|
|
21
|
+
mutationKey?: string;
|
|
22
|
+
retry?: number | boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type MutateResult<T> =
|
|
26
|
+
| { status: "skipped" }
|
|
27
|
+
| { status: "queued" }
|
|
28
|
+
| { status: "success"; data: T }
|
|
29
|
+
| { status: "error"; error: Error };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Composable for mutations (POST, PUT, PATCH, DELETE) with auto-invalidation, offline queuing,
|
|
33
|
+
* optimistic updates, and dual-level callbacks.
|
|
34
|
+
*
|
|
35
|
+
* Usage:
|
|
36
|
+
* const { mutate, isLoading, error, data } = useSwoffMutation("/api/todos", {
|
|
37
|
+
* method: "POST",
|
|
38
|
+
* onSuccess: (data) => navigate(`/item/${data.id}`),
|
|
39
|
+
* });
|
|
40
|
+
*
|
|
41
|
+
* // Check result status after mutate:
|
|
42
|
+
* const result = await mutate(body);
|
|
43
|
+
* if (result.status === "success") navigate(`/item/${result.data.id}`);
|
|
44
|
+
*/
|
|
45
|
+
export function useSwoffMutation<TData = unknown>(
|
|
46
|
+
url: string,
|
|
47
|
+
options: UseMutationOptions<TData> = {},
|
|
48
|
+
) {
|
|
49
|
+
const lastResult = ref<MutateResult<TData> | null>(null);
|
|
50
|
+
const isLoading = ref(false);
|
|
51
|
+
const mutationId = ref<string | null>(null);
|
|
52
|
+
|
|
53
|
+
const inFlightKeys = new Set<string>();
|
|
54
|
+
|
|
55
|
+
async function mutate(
|
|
56
|
+
body?: unknown,
|
|
57
|
+
callbacks?: MutateCallbacks<TData>,
|
|
58
|
+
): Promise<MutateResult<TData>> {
|
|
59
|
+
const key = options.mutationKey;
|
|
60
|
+
if (key && inFlightKeys.has(key)) return { status: "skipped" };
|
|
61
|
+
if (key) inFlightKeys.add(key);
|
|
62
|
+
|
|
63
|
+
let retriesLeft: number =
|
|
64
|
+
options.retry === true
|
|
65
|
+
? Infinity
|
|
66
|
+
: typeof options.retry === "number"
|
|
67
|
+
? options.retry
|
|
68
|
+
: 0;
|
|
69
|
+
|
|
70
|
+
const mid = "mut-" + crypto.randomUUID();
|
|
71
|
+
mutationId.value = mid;
|
|
72
|
+
trackMutation(mid, "pending");
|
|
73
|
+
isLoading.value = true;
|
|
74
|
+
|
|
75
|
+
options.onMutate?.();
|
|
76
|
+
|
|
77
|
+
const attempt = async (): Promise<MutateResult<TData>> => {
|
|
78
|
+
try {
|
|
79
|
+
const {
|
|
80
|
+
mutationKey,
|
|
81
|
+
onMutate,
|
|
82
|
+
onSuccess,
|
|
83
|
+
onError,
|
|
84
|
+
onSettled,
|
|
85
|
+
retry,
|
|
86
|
+
...fetchOnlyOptions
|
|
87
|
+
} = options;
|
|
88
|
+
const fetchOptions = { ...fetchOnlyOptions, body };
|
|
89
|
+
const { response, queued } = await fetchWithCache<TData>(
|
|
90
|
+
url,
|
|
91
|
+
fetchOptions as FetchWithCacheOptions,
|
|
92
|
+
);
|
|
93
|
+
if (queued) {
|
|
94
|
+
resolveMutation(mid, null);
|
|
95
|
+
isLoading.value = false;
|
|
96
|
+
const r: MutateResult<TData> = { status: "queued" };
|
|
97
|
+
lastResult.value = r;
|
|
98
|
+
options.onSettled?.();
|
|
99
|
+
callbacks?.onSettled?.();
|
|
100
|
+
if (key) inFlightKeys.delete(key);
|
|
101
|
+
return r;
|
|
102
|
+
}
|
|
103
|
+
const data: TData = await response.json();
|
|
104
|
+
resolveMutation(mid, data);
|
|
105
|
+
isLoading.value = false;
|
|
106
|
+
const r: MutateResult<TData> = { status: "success", data };
|
|
107
|
+
lastResult.value = r;
|
|
108
|
+
options.onSuccess?.(data);
|
|
109
|
+
callbacks?.onSuccess?.(data);
|
|
110
|
+
options.onSettled?.();
|
|
111
|
+
callbacks?.onSettled?.();
|
|
112
|
+
if (key) inFlightKeys.delete(key);
|
|
113
|
+
return r;
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (retriesLeft > 0) {
|
|
116
|
+
retriesLeft--;
|
|
117
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
118
|
+
return attempt();
|
|
119
|
+
}
|
|
120
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
121
|
+
rejectMutation(mid, error);
|
|
122
|
+
isLoading.value = false;
|
|
123
|
+
const r: MutateResult<TData> = { status: "error", error };
|
|
124
|
+
lastResult.value = r;
|
|
125
|
+
options.onError?.(error);
|
|
126
|
+
callbacks?.onError?.(error);
|
|
127
|
+
options.onSettled?.();
|
|
128
|
+
callbacks?.onSettled?.();
|
|
129
|
+
if (key) inFlightKeys.delete(key);
|
|
130
|
+
return r;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
return attempt();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function reset() {
|
|
138
|
+
lastResult.value = null;
|
|
139
|
+
isLoading.value = false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
mutate,
|
|
144
|
+
isLoading,
|
|
145
|
+
isIdle: lastResult.value === null,
|
|
146
|
+
isQueued: lastResult.value?.status === "queued",
|
|
147
|
+
isSuccess: lastResult.value?.status === "success",
|
|
148
|
+
isError: lastResult.value?.status === "error",
|
|
149
|
+
data: lastResult.value?.status === "success" ? (lastResult.value.data ?? null) : null,
|
|
150
|
+
error: lastResult.value?.status === "error" ? (lastResult.value.error ?? null) : null,
|
|
151
|
+
lastResult,
|
|
152
|
+
reset,
|
|
153
|
+
mutationId,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { ref, onMounted, onUnmounted } from "vue";
|
|
2
|
+
import { getMutationState, onMutationStateChange } from "../mutation/state.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Composable that subscribes to a specific mutation's state changes.
|
|
6
|
+
* Useful for showing per-mutation loading spinners, error states, etc.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* const mutation = useSwoffMutationState(mutationId);
|
|
10
|
+
* <div v-if="mutation?.status === 'error'">{{ mutation?.error?.message }}</div>
|
|
11
|
+
*/
|
|
12
|
+
export function useSwoffMutationState(id) {
|
|
13
|
+
const state = ref(null);
|
|
14
|
+
|
|
15
|
+
function update() {
|
|
16
|
+
state.value = id ? (getMutationState(id) ?? null) : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
onMounted(update);
|
|
20
|
+
|
|
21
|
+
let cleanup;
|
|
22
|
+
|
|
23
|
+
onMounted(() => {
|
|
24
|
+
const unsub = onMutationStateChange(update);
|
|
25
|
+
if (typeof unsub === "function") {
|
|
26
|
+
cleanup = unsub;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
onUnmounted(() => {
|
|
30
|
+
cleanup?.();
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return state;
|
|
35
|
+
}
|