@kawaiininja/fetch 1.0.17 โ†’ 1.0.18

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.
@@ -1,14 +1,7 @@
1
- import { BaseFetchOptions } from "./types";
2
1
  import { ApiSurface } from "./utils";
3
- /**
4
- * ๐Ÿ”ฅ SECURITY UPGRADE: Clear Vault
5
- * Call this on logout to ensure tokens are purged from memory.
6
- */
7
- export declare const clearNativeAuthVault: (debug?: boolean) => void;
2
+ export { clearNativeAuthVault } from "./useFetch.vault";
8
3
  /**
9
4
  * useFetch Hook
10
- *
11
- * A comprehensive hook for data fetching with automatic CSRF handling,
12
- * request cancellation, and a rich API surface.
5
+ * ๐Ÿ›ก๏ธ STABILITY UPGRADE: Internal Recursion Guard + Optimized Lifecycle
13
6
  */
14
- export declare const useFetch: <T = any>(endpoint: string, baseOptions?: BaseFetchOptions) => ApiSurface<T>;
7
+ export declare const useFetch: <T = any>(endpoint: string, baseOptions?: any) => ApiSurface<T>;
@@ -0,0 +1,6 @@
1
+ /**
2
+ * ๐Ÿ›ก๏ธ CORE FETCH EXECUTOR
3
+ * Handles the actual network request with retries and timeout.
4
+ */
5
+ export declare const performFetch: (url: string, method: string, token: string, body?: BodyInit, headers?: HeadersInit, rest?: RequestInit, debug?: boolean, abortSignal?: AbortSignal, apiUrl?: (path: string) => string) => Promise<Response>;
6
+ export declare const parseResponse: (res: Response, parseAs: string) => Promise<any>;
@@ -0,0 +1,84 @@
1
+ import { INTERNAL_HEADER } from "../context/ApiContext";
2
+ import { isNative } from "./platform";
3
+ import { initializeVault, nativeAuthVault } from "./useFetch.vault";
4
+ /**
5
+ * ๐Ÿ›ก๏ธ CORE FETCH EXECUTOR
6
+ * Handles the actual network request with retries and timeout.
7
+ */
8
+ export const performFetch = async (url, method, token, body, headers, rest, debug, abortSignal, apiUrl) => {
9
+ const _isNative = isNative();
10
+ const isInternal = url.startsWith("/") || (apiUrl && url.startsWith(apiUrl(""))) || false;
11
+ const headersConfig = {
12
+ ...(headers || {}),
13
+ ...INTERNAL_HEADER,
14
+ };
15
+ // ๐Ÿ”’ NATIVE SECURITY
16
+ if (_isNative && isInternal) {
17
+ await initializeVault(debug);
18
+ const authToken = nativeAuthVault.token;
19
+ const sessionId = nativeAuthVault.sessionId;
20
+ if (authToken)
21
+ headersConfig["Authorization"] = `Bearer ${authToken}`;
22
+ if (sessionId)
23
+ headersConfig["X-Session-ID"] = sessionId;
24
+ }
25
+ // ๐Ÿ”’ WEB SECURITY
26
+ if (!_isNative && isInternal && token) {
27
+ headersConfig["X-CSRF-Token"] = token;
28
+ }
29
+ return executeWithRetry(url, method, headersConfig, body, rest, debug, abortSignal);
30
+ };
31
+ /**
32
+ * Executes a fetch request with 3 retry attempts.
33
+ */
34
+ async function executeWithRetry(url, method, headers, body, rest, debug, abortSignal) {
35
+ let attempt = 0;
36
+ const maxAttempts = 3;
37
+ let lastError = null;
38
+ while (attempt < maxAttempts) {
39
+ attempt++;
40
+ const TIMEOUT_MS = 15000;
41
+ const controller = new AbortController();
42
+ const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
43
+ // Link to external abort signal if provided
44
+ const onAbort = () => controller.abort();
45
+ if (abortSignal)
46
+ abortSignal.addEventListener("abort", onAbort);
47
+ try {
48
+ const response = await Promise.race([
49
+ fetch(url, { ...rest, method, signal: controller.signal, credentials: "include", headers, body }),
50
+ new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout of ${TIMEOUT_MS}ms exceeded`)), TIMEOUT_MS)),
51
+ ]);
52
+ clearTimeout(timeoutId);
53
+ if (abortSignal)
54
+ abortSignal.removeEventListener("abort", onAbort);
55
+ return response;
56
+ }
57
+ catch (error) {
58
+ clearTimeout(timeoutId);
59
+ if (abortSignal)
60
+ abortSignal.removeEventListener("abort", onAbort);
61
+ lastError = error;
62
+ if (error.name === "AbortError" || abortSignal?.aborted)
63
+ throw error;
64
+ if (debug)
65
+ console.warn(`[useFetch] Attempt ${attempt} failed: ${error.message}.`);
66
+ if (attempt < maxAttempts)
67
+ await new Promise((r) => setTimeout(r, 1000));
68
+ }
69
+ }
70
+ throw lastError || new Error("Request failed after 3 attempts");
71
+ }
72
+ export const parseResponse = async (res, parseAs) => {
73
+ const type = res.headers.get("content-type") || "";
74
+ if (parseAs === "json" || (parseAs === "auto" && type.includes("application/json"))) {
75
+ return res.json();
76
+ }
77
+ else if (parseAs === "text") {
78
+ return res.text();
79
+ }
80
+ else if (parseAs === "blob") {
81
+ return res.blob();
82
+ }
83
+ return res.text();
84
+ };
@@ -1,295 +1,89 @@
1
- import { SecureStoragePlugin } from "capacitor-secure-storage-plugin";
2
1
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
3
- import { INTERNAL_HEADER } from "../context/ApiContext";
4
- import { isNative } from "./platform";
5
2
  import { useApiConfig } from "./useApiConfig";
6
3
  import { useCsrf } from "./useCsrf";
4
+ import { parseResponse, performFetch } from "./useFetch.executor";
7
5
  import { createApiMethods } from "./utils";
8
- /**
9
- * ๐Ÿ›ก๏ธ SESSION MEMORY VAULT
10
- * Caches native tokens in memory for the lifetime of the JS process.
11
- * This avoids expensive SecureStorage calls on every request.
12
- */
13
- const nativeAuthVault = {
14
- token: undefined,
15
- sessionId: undefined,
16
- loaded: false,
17
- /**
18
- * ๐Ÿ›ก๏ธ RECURSION GUARD: Initialization Promise
19
- * Prevents multiple concurrent requests from triggering separate
20
- * SecureStorage calls. All requests will await the same first check.
21
- */
22
- initPromise: null,
23
- };
24
- /**
25
- * ๐Ÿ”ฅ SECURITY UPGRADE: Clear Vault
26
- * Call this on logout to ensure tokens are purged from memory.
27
- */
28
- export const clearNativeAuthVault = (debug) => {
29
- nativeAuthVault.loaded = false;
30
- nativeAuthVault.token = undefined;
31
- nativeAuthVault.sessionId = undefined;
32
- if (debug)
33
- console.log("[useFetch] [Native] Auth vault cleared.");
34
- };
6
+ export { clearNativeAuthVault } from "./useFetch.vault";
35
7
  /**
36
8
  * useFetch Hook
37
- *
38
- * A comprehensive hook for data fetching with automatic CSRF handling,
39
- * request cancellation, and a rich API surface.
9
+ * ๐Ÿ›ก๏ธ STABILITY UPGRADE: Internal Recursion Guard + Optimized Lifecycle
40
10
  */
41
11
  export const useFetch = (endpoint, baseOptions = {}) => {
42
12
  const { apiUrl, onError, debug } = useApiConfig();
43
13
  const { fetchCSRF, clearCsrf } = useCsrf();
44
14
  const runsRef = useRef(0);
45
- const resolvedUrl = endpoint.startsWith("http") ? endpoint : apiUrl(endpoint);
46
15
  const abortRef = useRef(new AbortController());
47
16
  const mounted = useRef(true);
48
- const optionsRef = useRef(baseOptions);
17
+ const isRequestingRef = useRef(false);
49
18
  const [state, setState] = useState({
50
19
  data: null,
51
20
  loading: false,
52
21
  error: null,
53
22
  status: null,
54
23
  });
55
- useEffect(() => {
56
- optionsRef.current = baseOptions;
57
- }, [baseOptions]);
58
24
  const safeSet = useCallback((fn) => {
59
- if (mounted.current) {
25
+ if (mounted.current)
60
26
  setState((prev) => ({ ...prev, ...fn(prev) }));
61
- }
62
27
  }, []);
63
- const performFetch = async (url, method, token, body, headers, rest) => {
64
- // ๐ŸŒ PLATFORM DETECTION
65
- const _isNative = isNative();
66
- // ๐ŸŽฏ TARGET CHECK (Prevent Credential Leakage)
67
- // Only attach sensitive headers if sending to our own API
68
- const isInternal = url.startsWith("/") ||
69
- (apiUrl("") && url.startsWith(apiUrl(""))) ||
70
- false;
71
- const headersConfig = {
72
- ...(optionsRef.current.headers || {}),
73
- ...(headers || {}),
74
- ...INTERNAL_HEADER,
75
- };
76
- // ๐Ÿ”’ SECURITY STRATEGY: NATIVE (MOBILE)
77
- if (_isNative && isInternal) {
78
- if (!nativeAuthVault.loaded) {
79
- if (debug)
80
- console.log("[useFetch] [Native] Vault not loaded, initializing...");
81
- if (!nativeAuthVault.initPromise) {
82
- nativeAuthVault.initPromise = (async () => {
83
- try {
84
- if (debug)
85
- console.log("[useFetch] [Native] Reading SecureStorage...");
86
- const { value: t } = await SecureStoragePlugin.get({
87
- key: "token",
88
- });
89
- const { value: s } = await SecureStoragePlugin.get({
90
- key: "session_id",
91
- });
92
- nativeAuthVault.token = t || undefined;
93
- nativeAuthVault.sessionId = s || undefined;
94
- if (!nativeAuthVault.token) {
95
- if (debug)
96
- console.log("[useFetch] [Native] SecureStorage empty, trying localStorage fallback");
97
- nativeAuthVault.token =
98
- localStorage.getItem("token") || undefined;
99
- nativeAuthVault.sessionId =
100
- localStorage.getItem("session_id") || undefined;
101
- }
102
- nativeAuthVault.loaded = true;
103
- if (debug)
104
- console.log("[useFetch] [Native] Auth vault initialized.");
105
- }
106
- catch (err) {
107
- if (debug)
108
- console.warn("[useFetch] [Native] SecureStorage failed, falling back to localStorage:", err);
109
- nativeAuthVault.token =
110
- localStorage.getItem("token") || undefined;
111
- nativeAuthVault.sessionId =
112
- localStorage.getItem("session_id") || undefined;
113
- nativeAuthVault.loaded = true;
114
- }
115
- finally {
116
- nativeAuthVault.initPromise = null;
117
- }
118
- })();
119
- }
120
- await nativeAuthVault.initPromise;
121
- if (debug)
122
- console.log("[useFetch] [Native] Vault ready.");
123
- }
124
- const authToken = nativeAuthVault.token;
125
- const sessionId = nativeAuthVault.sessionId;
126
- if (authToken)
127
- headersConfig["Authorization"] = `Bearer ${authToken}`;
128
- if (sessionId)
129
- headersConfig["X-Session-ID"] = sessionId;
130
- }
131
- // ๐Ÿ”’ SECURITY STRATEGY: WEB (BROWSER)
132
- if (!_isNative && isInternal) {
133
- // Web relies on Cookies ("Passive Courier") for Auth
134
- if (token) {
135
- headersConfig["X-CSRF-Token"] = token;
136
- }
137
- }
138
- // ๐Ÿ›ก๏ธ RETRY LOGIC (3 Attempts)
139
- // We try 3 times. If all 3 fail, we throw.
140
- let attempt = 0;
141
- const maxAttempts = 3;
142
- let lastError = null;
143
- while (attempt < maxAttempts) {
144
- attempt++;
145
- const TIMEOUT_MS = 15000;
146
- const controller = new AbortController();
147
- const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
148
- // Link to component lifecycle
149
- const onUnmount = () => controller.abort();
150
- abortRef.current.signal.addEventListener("abort", onUnmount);
151
- try {
152
- const response = await Promise.race([
153
- fetch(url, {
154
- ...optionsRef.current,
155
- ...rest,
156
- method,
157
- signal: controller.signal,
158
- credentials: "include",
159
- headers: headersConfig,
160
- body,
161
- }),
162
- new Promise((_, reject) => setTimeout(() => reject(new Error(`Timeout of ${TIMEOUT_MS}ms exceeded`)), TIMEOUT_MS)),
163
- ]);
164
- clearTimeout(timeoutId);
165
- abortRef.current.signal.removeEventListener("abort", onUnmount);
166
- return response; // Success!
167
- }
168
- catch (error) {
169
- clearTimeout(timeoutId);
170
- abortRef.current.signal.removeEventListener("abort", onUnmount);
171
- lastError = error;
172
- // If aborted by user/unmount, DO NOT RETRY
173
- if (error.name === "AbortError" || abortRef.current.signal.aborted) {
174
- throw error;
175
- }
176
- if (debug) {
177
- console.warn(`[useFetch] Attempt ${attempt} failed: ${error.message}. Retrying...`);
178
- }
179
- // Wait 1s before retry
180
- if (attempt < maxAttempts) {
181
- await new Promise((r) => setTimeout(r, 1000));
182
- }
183
- }
184
- }
185
- throw lastError || new Error("Request failed after 3 attempts");
186
- };
187
- const parseResponse = async (res, parseAs) => {
188
- const type = res.headers.get("content-type") || "";
189
- if (parseAs === "json" ||
190
- (parseAs === "auto" && type.includes("application/json"))) {
191
- return res.json();
192
- }
193
- else if (parseAs === "text") {
194
- return res.text();
195
- }
196
- else if (parseAs === "blob") {
197
- return res.blob();
198
- }
199
- return res.text();
200
- };
201
- // ๐Ÿ›ก๏ธ RECURSION GUARD
202
- const isRequestingRef = useRef(false);
203
- // ๐Ÿš€ STABLE REQUEST METHOD
204
28
  const request = useCallback(async (params = {}) => {
29
+ // ๐Ÿ›ก๏ธ RECURSION GUARD: Instant rejection of concurrent loops
205
30
  if (isRequestingRef.current) {
206
31
  if (debug)
207
- console.warn("[useFetch] ๐Ÿ”ฅ RECURSION GUARD: Blocking concurrent request to prevent stack overflow.");
32
+ console.warn("[useFetch] ๐Ÿ”ฅ RECURSION GUARD: Blocking concurrent call.");
208
33
  return;
209
34
  }
210
35
  const { url = endpoint, method = "GET", body, headers, parseAs = "auto", ...rest } = params;
211
- let finalUrl = url.startsWith("http") ? url : apiUrl(url);
212
- const runs = ++runsRef.current;
213
- if (debug) {
214
- console.group(`[useFetch] Request #${runs}: ${method} ${finalUrl}`);
215
- console.log(`[useFetch] Config:`, {
216
- isNative: isNative(),
217
- parseAs,
218
- endpoint,
219
- });
220
- console.groupEnd();
221
- }
222
- else {
223
- console.log(`[useFetch] Request initiated: ${method} ${finalUrl}`);
224
- }
225
- if (!finalUrl) {
226
- console.error("[useFetch] Aborted: Empty URL");
36
+ const finalUrl = url.startsWith("http") ? url : apiUrl(url);
37
+ if (!finalUrl)
227
38
  return;
228
- }
229
39
  isRequestingRef.current = true;
230
40
  safeSet(() => ({ loading: true, error: null }));
231
41
  let lastStatus = null;
232
42
  try {
233
- let csrfToken = "";
234
- csrfToken = await fetchCSRF();
235
- let res = await performFetch(finalUrl, method, csrfToken, body, headers, rest);
43
+ let token = await fetchCSRF();
44
+ let res = await performFetch(finalUrl, method, token, body, headers, { ...baseOptions, ...rest }, debug, abortRef.current.signal, apiUrl);
236
45
  lastStatus = res.status;
237
- // ๐Ÿ”„ Auto-retry on CSRF error (One-time only)
46
+ // Auto-retry on 403 CSRF Error
238
47
  if (res.status === 403) {
239
- try {
240
- const errorBody = await res.clone().json();
241
- if (errorBody?.code === "CSRF_ERROR" ||
242
- errorBody?.message === "Invalid or missing CSRF token") {
243
- await clearCsrf();
244
- csrfToken = await fetchCSRF();
245
- res = await performFetch(finalUrl, method, csrfToken, body, headers, rest);
246
- lastStatus = res.status;
247
- }
248
- }
249
- catch (e) {
250
- /* ignore parse error */
48
+ const bodyClone = await res
49
+ .clone()
50
+ .json()
51
+ .catch(() => ({}));
52
+ if (bodyClone?.code === "CSRF_ERROR" || bodyClone?.message?.includes("CSRF")) {
53
+ await clearCsrf();
54
+ token = await fetchCSRF();
55
+ res = await performFetch(finalUrl, method, token, body, headers, { ...baseOptions, ...rest }, debug, abortRef.current.signal, apiUrl);
56
+ lastStatus = res.status;
251
57
  }
252
58
  }
253
59
  const parsed = await parseResponse(res, parseAs);
254
- if (!res.ok) {
60
+ if (!res.ok)
255
61
  throw new Error(parsed?.message || res.statusText);
256
- }
257
62
  safeSet(() => ({ data: parsed, status: res.status }));
258
63
  return parsed;
259
64
  }
260
65
  catch (err) {
261
66
  if (err.name !== "AbortError") {
262
- console.error("[useFetch] Request Failed:", err);
263
67
  safeSet(() => ({ error: err.message, status: lastStatus }));
264
68
  if (onError)
265
69
  onError(err.message, lastStatus);
266
70
  throw err;
267
71
  }
268
- else {
269
- // Silently ignore component unmount aborts
270
- if (debug)
271
- console.log("[useFetch] Request aborted (component unmounted).");
272
- }
273
72
  }
274
73
  finally {
275
74
  safeSet(() => ({ loading: false }));
276
75
  isRequestingRef.current = false;
277
76
  }
278
- }, [endpoint, fetchCSRF, clearCsrf, apiUrl, safeSet, onError, debug]);
279
- // ๐Ÿงน LIFECYCLE
77
+ }, [endpoint, fetchCSRF, clearCsrf, apiUrl, safeSet, onError, debug, baseOptions]);
280
78
  useEffect(() => {
281
79
  mounted.current = true;
282
- if (debug) {
283
- console.log(`[useFetch] [Init] Hook initialized for: ${endpoint}`);
284
- }
285
80
  return () => {
286
81
  mounted.current = false;
287
82
  abortRef.current?.abort();
288
83
  };
289
- }, [debug, endpoint]);
290
- // ๐Ÿน STABLE API SURFACE
84
+ }, []);
85
+ const resolvedUrl = useMemo(() => (endpoint.startsWith("http") ? endpoint : apiUrl(endpoint)), [endpoint, apiUrl]);
291
86
  const methods = useMemo(() => createApiMethods({ request, baseUrl: resolvedUrl, safeSet }), [request, resolvedUrl, safeSet]);
292
- // 2. Merge state with methods
293
87
  return useMemo(() => ({
294
88
  state,
295
89
  isLoading: state.loading,
@@ -0,0 +1,25 @@
1
+ /**
2
+ * ๐Ÿ›ก๏ธ SESSION MEMORY VAULT
3
+ * Caches native tokens in memory for the lifetime of the JS process.
4
+ * This avoids expensive SecureStorage calls on every request.
5
+ */
6
+ export declare const nativeAuthVault: {
7
+ token: string | undefined;
8
+ sessionId: string | undefined;
9
+ loaded: boolean;
10
+ /**
11
+ * ๐Ÿ›ก๏ธ RECURSION GUARD: Initialization Promise
12
+ * Prevents multiple concurrent requests from triggering separate
13
+ * SecureStorage calls. All requests will await the same first check.
14
+ */
15
+ initPromise: Promise<void> | null;
16
+ };
17
+ /**
18
+ * ๐Ÿ”ฅ SECURITY UPGRADE: Clear Vault
19
+ * Call this on logout to ensure tokens are purged from memory.
20
+ */
21
+ export declare const clearNativeAuthVault: (debug?: boolean) => void;
22
+ /**
23
+ * Initialize the auth vault from SecureStorage or localStorage.
24
+ */
25
+ export declare const initializeVault: (debug?: boolean) => Promise<void>;
@@ -0,0 +1,67 @@
1
+ import { SecureStoragePlugin } from "capacitor-secure-storage-plugin";
2
+ /**
3
+ * ๐Ÿ›ก๏ธ SESSION MEMORY VAULT
4
+ * Caches native tokens in memory for the lifetime of the JS process.
5
+ * This avoids expensive SecureStorage calls on every request.
6
+ */
7
+ export const nativeAuthVault = {
8
+ token: undefined,
9
+ sessionId: undefined,
10
+ loaded: false,
11
+ /**
12
+ * ๐Ÿ›ก๏ธ RECURSION GUARD: Initialization Promise
13
+ * Prevents multiple concurrent requests from triggering separate
14
+ * SecureStorage calls. All requests will await the same first check.
15
+ */
16
+ initPromise: null,
17
+ };
18
+ /**
19
+ * ๐Ÿ”ฅ SECURITY UPGRADE: Clear Vault
20
+ * Call this on logout to ensure tokens are purged from memory.
21
+ */
22
+ export const clearNativeAuthVault = (debug) => {
23
+ nativeAuthVault.loaded = false;
24
+ nativeAuthVault.token = undefined;
25
+ nativeAuthVault.sessionId = undefined;
26
+ if (debug)
27
+ console.log("[useFetch] [Native] Auth vault cleared.");
28
+ };
29
+ /**
30
+ * Initialize the auth vault from SecureStorage or localStorage.
31
+ */
32
+ export const initializeVault = async (debug) => {
33
+ if (nativeAuthVault.loaded)
34
+ return;
35
+ if (nativeAuthVault.initPromise)
36
+ return nativeAuthVault.initPromise;
37
+ nativeAuthVault.initPromise = (async () => {
38
+ try {
39
+ if (debug)
40
+ console.log("[useFetch] [Native] Reading SecureStorage...");
41
+ const { value: t } = await SecureStoragePlugin.get({ key: "token" });
42
+ const { value: s } = await SecureStoragePlugin.get({ key: "session_id" });
43
+ nativeAuthVault.token = t || undefined;
44
+ nativeAuthVault.sessionId = s || undefined;
45
+ if (!nativeAuthVault.token) {
46
+ if (debug)
47
+ console.log("[useFetch] [Native] SecureStorage empty, trying localStorage fallback");
48
+ nativeAuthVault.token = localStorage.getItem("token") || undefined;
49
+ nativeAuthVault.sessionId = localStorage.getItem("session_id") || undefined;
50
+ }
51
+ nativeAuthVault.loaded = true;
52
+ if (debug)
53
+ console.log("[useFetch] [Native] Auth vault initialized.");
54
+ }
55
+ catch (err) {
56
+ if (debug)
57
+ console.warn("[useFetch] [Native] SecureStorage failed, falling back to localStorage:", err);
58
+ nativeAuthVault.token = localStorage.getItem("token") || undefined;
59
+ nativeAuthVault.sessionId = localStorage.getItem("session_id") || undefined;
60
+ nativeAuthVault.loaded = true;
61
+ }
62
+ finally {
63
+ nativeAuthVault.initPromise = null;
64
+ }
65
+ })();
66
+ return nativeAuthVault.initPromise;
67
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kawaiininja/fetch",
3
- "version": "1.0.17",
3
+ "version": "1.0.18",
4
4
  "description": "Core fetch utility for Onyx Framework",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",