UNPKG

valync

Version:

**A lightweight, framework-agnostic async data handling library for React & Vue, inspired by Riverpod’s AsyncValue pattern and powered by ts-results-es.**

321 lines 12.5 kB
import { ref, onMounted, watch, onUnmounted } from "vue"; import { Some, None } from "ts-results-es"; import { normalizeKey, AsyncLoading, AsyncError, AsyncData, AsyncObserver, } from "../core/index"; const cache = new Map(); /** * createValyn creates a custom `useValync` hook bound to a provided HTTP client function. * Useful for plugging in your own fetch logic or a library like axios. * * ⚠️ NOTE: * Your `client()` function MUST return a Promise resolving to: * * ApiResponse<any> * * { * status: "success" | "failed", * data?: T, * error?: { name: string; message: string; code?: number } * } * * use `onData` to apply transformation from `any => T` for individual endpoint when neccessary. * Returning a plain array or object without the `status` field will cause issues. */ export function createValyn({ client, options: _options = {}, }) { return function (key, options = {}) { let intervalId; const initRef = options.init ?? ref({}); initRef.value = { ...initRef.value, headers: { ...initRef.value.headers, ..._options.headers }, }; options.init.value = { ...options.init.value, headers: { ...options.init.value.headers, ..._options.headers }, }; options.cache = options.cache ?? _options.cache; options.retryCount = options.retryCount ?? _options.retryCount; options.fetchOnMount = options.fetchOnMount ?? _options.fetchOnMount; const keyStr = normalizeKey(key); const controller = ref(); let initialValue; if (options.initialData) { initialValue = options.initialData.status === "success" ? new AsyncData(Some(options.initialData.data)) : new AsyncError(options.initialData.error); } else if (options.cache !== false && cache.has(keyStr)) { initialValue = cache.get(keyStr); } else { initialValue = new AsyncData(None); } const observerRef = ref(new AsyncObserver(initialValue)); const state = ref(initialValue); const isClient = typeof window !== "undefined" && typeof AbortController !== "undefined"; const doFetch = (method, body) => { controller.value?.abort(); controller.value = new AbortController(); if (options.cache !== false && cache.has(keyStr)) { state.value = cache.get(keyStr); return; } state.value = new AsyncLoading(); const attempt = (tries) => { client(typeof key === "string" ? key : keyStr, { ...options.init.value, method: method ?? options.init.value?.method ?? (body ? "POST" : "GET"), body: body ?? options.init.value?.body, signal: controller.value.signal, }) .then((res) => { if (controller.value.signal.aborted) return; // DEV-ONLY: Validate ApiResponse<T> format if (process.env.NODE_ENV !== "production" && (typeof res !== "object" || !("status" in res) || (res.status !== "success" && res.status !== "failed"))) { console.warn(`[Valync] Expected ApiResponse<T> format missing from client() response. Got:`, res); } if (res.status === "failed") { options.onError && options.onError(res.error); state.value = new AsyncError(res.error); } else { const data = options.onData?.(res.data) ?? res.data; options.onSuccess?.(data); const sd = new AsyncData(Some(data)); if (options.cache !== false) cache.set(keyStr, sd); state.value = sd; } }) .catch((err) => { if (controller.value.signal.aborted) return; if (tries > 0) return attempt(tries - 1); options.onError?.({ name: "NetworkError", message: err.message, code: "500", }); state.value = new AsyncError({ name: "NetworkError", message: err.message, code: "500", }); }); }; attempt(options.retryCount ?? 0); }; if (isClient) { onMounted(() => { if (options.fetchOnMount !== false && !options.initialData) { doFetch(); } if (options.fetchInterval) { intervalId = window.setInterval(doFetch, options.fetchInterval); } }); onUnmounted(() => { controller.value?.abort(); if (intervalId) clearInterval(intervalId); }); } if (isClient && options.watch && options.watch.length > 0) { watch(options.watch, () => doFetch()); } watch(state, () => { observerRef.value.set(state.value); }); const fetchFn = (methodOrOpts, body) => { if (!isClient) return; cache.delete(normalizeKey(keyStr)); if (typeof methodOrOpts === "string") { doFetch(methodOrOpts, body); } else { doFetch(methodOrOpts?.method, methodOrOpts?.body); } }; const setData = (updater) => { const currentVal = state.value instanceof AsyncData ? state.value.value.isSome() ? state.value.value.unwrap() : null : null; const newVal = new AsyncData(Some(updater(currentVal))); if (options.cache !== false) cache.set(keyStr, newVal); state.value = newVal; }; return [state, fetchFn, setData, observerRef.value.observer()]; }; } /** * useValync is a client-side data fetching hook that provides async state management * with caching, optimistic updates, and reactive watching support. * * ⚠️ NOTE: * Your server MUST return a JSON response of the shape: * * ApiResponse<T> | ApiResponse<any> * * { * status: "success" | "failed", * data?: T, * error?: { name: string; message: string; code?: number } * } * * Use `onData` if `res.data` does not match your expected frontend type or if you wish to apply transformation, * so returning a plain array or object without the `status` field will cause issues. */ export function useValync(key, options = {}) { let intervalId; const keyStr = normalizeKey(key); const controller = ref(); let initialValue; if (options.initialData) { initialValue = options.initialData.status === "success" ? new AsyncData(Some(options.initialData.data)) : new AsyncError(options.initialData.error); } else if (options.cache !== false && cache.has(keyStr)) { initialValue = cache.get(keyStr); } else { initialValue = new AsyncData(None); } const observer = ref(new AsyncObserver(initialValue)); const state = ref(initialValue); const isClient = typeof window !== "undefined" && typeof AbortController !== "undefined"; const doFetch = (method, body) => { controller.value?.abort(); controller.value = new AbortController(); if (options.cache !== false && cache.has(keyStr)) { state.value = cache.get(keyStr); return; } state.value = new AsyncLoading(); const attempt = (tries) => { fetch(typeof key === "string" ? key : keyStr, { ...options.init.value, method: method ?? options.init.value?.method ?? (body ? "POST" : "GET"), body: body ?? options.init.value?.body, signal: controller.value.signal, }) .then(async (resp) => { let json; try { json = await resp.json(); } catch { return { status: "failed", error: { name: "ParseError", message: "Invalid JSON", }, }; } // DEV-ONLY: Validate ApiResponse format if (process.env.NODE_ENV !== "production" && (typeof json !== "object" || !("status" in json) || (json.status !== "success" && json.status !== "failed"))) { console.warn(`[Valync] Expected ApiResponse<T> format missing. Got:`, json); } if (!resp.ok || json.status === "failed") { return { status: "failed", error: json?.error ?? { name: "HttpError", message: resp.statusText, code: resp.status, }, }; } return json; }) .then((res) => { if (controller.value.signal.aborted) return; if (res.status === "failed") state.value = new AsyncError(res.error); else { const data = options.onData?.(res.data) ?? res.data; options.onSuccess?.(data); const sd = new AsyncData(Some(data)); if (options.cache !== false) cache.set(keyStr, sd); state.value = sd; } }) .catch((err) => { if (controller.value.signal.aborted) return; if (tries > 0) return attempt(tries - 1); options.onError?.({ name: "NetworkError", message: err.message, }); state.value = new AsyncError({ name: "NetworkError", message: err.message, }); }); }; attempt(options.retryCount ?? 0); }; if (isClient) { onMounted(() => { if (options.fetchOnMount !== false && !options.initialData) { doFetch(); } if (options.fetchInterval) { intervalId = window.setInterval(doFetch, options.fetchInterval); } }); onUnmounted(() => { controller.value?.abort(); if (intervalId) clearInterval(intervalId); }); } if (isClient && options.watch && options.watch.length > 0) { watch(options.watch, () => doFetch()); } watch(state, () => { observer.value.set(state.value); }); const fetchFn = (methodOrOpts, body) => { if (!isClient) return; cache.delete(normalizeKey(keyStr)); if (typeof methodOrOpts === "string") { doFetch(methodOrOpts, body); } else { doFetch(methodOrOpts?.method, methodOrOpts?.body); } }; const setData = (updater) => { const currentVal = state.value instanceof AsyncData ? state.value.value.isSome() ? state.value.value.unwrap() : null : null; const newVal = new AsyncData(Some(updater(currentVal))); if (options.cache !== false) cache.set(keyStr, newVal); state.value = newVal; }; return [state, fetchFn, setData, observer.value.observer()]; } //# sourceMappingURL=index.js.map