import { dehydrate, QueryClient, useInfiniteQuery, useQuery, useQueryClient, useMutation as useRQMutation, } from "@tanstack/react-query"; import { useContext } from "react"; import { Platform } from "react-native"; import type { z } from "zod/v4"; import { type KyooError, type Page, Paged } from "~/models"; import { AccountContext } from "~/providers/account-context"; import { setServerData } from "~/utils"; const ssrApiUrl = process.env.KYOO_URL ?? "http://api:3567/api"; const cleanSlash = (str: string | null) => { if (str === null) return null; return str.replace(/\/$/g, ""); }; export const queryFn = async (context: { method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; url: string; body?: object; formData?: FormData; plainText?: boolean; authToken: string | null; parser: Parser | null; signal?: AbortSignal; }): Promise> => { if ( Platform.OS === "web" && typeof window === "undefined" && context.url.startsWith("/") ) context.url = `${ssrApiUrl}${context.url}`; let resp: Response; try { resp = await fetch(context.url, { method: context.method, body: "body" in context && context.body ? JSON.stringify(context.body) : "formData" in context && context.formData ? context.formData : undefined, headers: { ...(context.authToken ? { Authorization: `Bearer ${context.authToken}` } : {}), ...("body" in context ? { "Content-Type": "application/json" } : {}), }, signal: context.signal, }); } catch (e) { if (typeof e === "object" && e && "name" in e && e.name === "AbortError") throw { message: "Aborted", status: "aborted" } as KyooError; console.log("Fetch error", e, context.url); throw { message: "Could not reach Kyoo's server.", status: "aborted", } as KyooError; } if (resp.status === 404) { throw { message: "Resource not found.", status: 404 } as KyooError; } if (!resp.ok) { const error = await resp.text(); let data: Record; try { data = JSON.parse(error); } catch (e) { data = { message: error } as KyooError; } data.status = resp.status; console.log( `Invalid response (${context.method ?? "GET"} ${context.url}):`, data, resp.status, ); throw data as KyooError; } if (resp.status === 204) return null!; if (context.plainText) return (await resp.text()) as any; let data: Record; try { data = await resp.json(); } catch (e) { console.error("Invalid json from kyoo", e); throw { message: "Invalid response from kyoo", status: "json", } as KyooError; } if (!context.parser) return data as any; const parsed = await context.parser.safeParseAsync(data); if (!parsed.success) { console.log( "Url: ", context.url, " Response: ", resp.status, " Parse error: ", parsed.error, ); console.log(parsed.error.issues); throw { status: "parse", message: "Invalid response from kyoo. Possible version mismatch between the server and the application.", } as KyooError; } return parsed.data; }; export const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { // 5min staleTime: 300_000, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, }, }, }); export type QueryIdentifier = { parser: z.ZodType | null; path: (string | undefined)[]; params?: { [query: string]: boolean | number | string | string[] | undefined; }; infinite?: boolean; placeholderData?: T | (() => T); enabled?: boolean; options?: Partial[0]> & { apiUrl?: string; }; }; const toQueryKey = (query: { apiUrl: string; path: (string | undefined)[]; params?: { [query: string]: boolean | number | string | string[] | undefined; }; }) => { return [ cleanSlash(query.apiUrl), ...query.path, query.params ? `?${Object.entries(query.params) .filter(([_, v]) => v !== undefined) .map(([k, v]) => `${k}=${Array.isArray(v) ? v.join(",") : v}`) .join("&")}` : undefined, ].filter((x) => x !== undefined); }; export const keyToUrl = (key: ReturnType) => { return key.join("/").replace("/?", "?"); }; export const useFetch = (query: QueryIdentifier) => { let { apiUrl, authToken } = useContext(AccountContext); if (query.options?.apiUrl) apiUrl = query.options.apiUrl; const key = toQueryKey({ apiUrl, path: query.path, params: query.params }); return useQuery({ queryKey: key, queryFn: (ctx) => queryFn({ url: keyToUrl(key), parser: query.parser, signal: ctx.signal, authToken: authToken ?? null, ...query.options, }) as Promise, placeholderData: query.placeholderData as any, enabled: query.enabled, }); }; export const useInfiniteFetch = (query: QueryIdentifier) => { let { apiUrl, authToken } = useContext(AccountContext); if (query.options?.apiUrl) apiUrl = query.options.apiUrl; const key = toQueryKey({ apiUrl, path: query.path, params: query.params }); const res = useInfiniteQuery, KyooError>({ queryKey: key, queryFn: (ctx) => queryFn({ url: (ctx.pageParam as string) ?? keyToUrl(key), parser: query.parser ? Paged(query.parser) : null, signal: ctx.signal, authToken: authToken ?? null, ...query.options, }) as Promise>, getNextPageParam: (page: Page) => page?.next || undefined, initialPageParam: undefined, placeholderData: query.placeholderData as any, enabled: query.enabled, }); const ret = res as typeof res & { items?: Data[] }; ret.items = ret.data?.pages.flatMap((x) => x.items); return ret; }; export const prefetch = async (...queries: QueryIdentifier[]) => { const client = createQueryClient(); const authToken = undefined; await Promise.all( queries .filter((x) => x.enabled !== false) .map((query) => { const key = toQueryKey({ apiUrl: ssrApiUrl, path: query.path, params: query.params, }); if (query.infinite) { return client.prefetchInfiniteQuery({ queryKey: key, queryFn: (ctx) => queryFn({ url: keyToUrl(key), parser: query.parser ? Paged(query.parser) : null, signal: ctx.signal, authToken: authToken ?? null, ...query.options, }), initialPageParam: undefined, }); } return client.prefetchQuery({ queryKey: key, queryFn: (ctx) => queryFn({ url: keyToUrl(key), parser: query.parser, signal: ctx.signal, authToken: authToken ?? null, ...query.options, }), }); }), ); setServerData("queryState", dehydrate(client)); return client; }; type MutationParams = { method?: "POST" | "PUT" | "DELETE"; path?: string[]; params?: { [query: string]: boolean | number | string | string[] | undefined; }; body?: object; }; export const useMutation = ({ compute, invalidate, ...queryParams }: MutationParams & { compute?: (param: T) => MutationParams; invalidate: string[] | null; }) => { const { apiUrl, authToken } = useContext(AccountContext); const queryClient = useQueryClient(); const mutation = useRQMutation({ mutationFn: (param: T) => { const { method, path, params, body } = { ...queryParams, ...compute?.(param), } as Required; return queryFn({ method, url: keyToUrl(toQueryKey({ apiUrl, path, params })), body, authToken, parser: null, }); }, onSuccess: invalidate ? async () => await queryClient.invalidateQueries({ queryKey: toQueryKey({ apiUrl, path: invalidate }), }) : undefined, // TODO: Do something // onError: () => {} }); return mutation; };