import { QueryClient, dehydrate, useInfiniteQuery, useQuery } from "@tanstack/react-query"; import { setServerData } from "one"; import { type ComponentType, type ReactElement, useContext } from "react"; import type { z } from "zod"; import { type KyooError, type Page, Paged } from "~/models"; import { AccountContext } from "~/providers/account-provider"; const cleanSlash = (str: string | null, keepFirst = false) => { if (!str) return null; if (keepFirst) return str.replace(/\/$/g, ""); 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; signal: AbortSignal; }): Promise> => { 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.trace( `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 unknown; 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; const parsed = await context.parser.safeParseAsync(data); if (!parsed.success) { console.log("Url: ", context.url, " Response: ", resp.status, " Parse error: ", parsed.error); throw { status: "parse", message: "Invalid response from kyoo. Possible version mismatch between the server and the application.", } as KyooError; } return parsed.data; }; export type MutationParam = { params?: Record; body?: object; path: string[]; method: "POST" | "DELETE"; }; export const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { // 5min staleTime: 300_000, refetchOnWindowFocus: false, refetchOnReconnect: false, retry: false, }, mutations: { mutationFn: (({ method, path, body, params }: MutationParam) => { return queryFn({ method, path: toQueryKey({ path, params }), body, }); }) as any, }, }, }); export type QueryIdentifier = { parser: z.ZodType; path: (string | undefined)[]; params?: { [query: string]: boolean | number | string | string[] | undefined }; infinite?: boolean | { value: true; map?: (x: any[]) => Ret[] }; placeholderData?: T | (() => T); enabled?: boolean; options?: Partial[0]> & { apiUrl?: string; }; }; export type QueryPage = ComponentType< Props & { randomItems: Items[] } > & { getFetchUrls?: (route: { [key: string]: string }, randomItems: Items[]) => QueryIdentifier[]; getLayout?: | QueryPage<{ page: ReactElement }> | { Layout: QueryPage<{ page: ReactElement }>; props: object }; requiredPermissions?: string[]; randomItems?: Items[]; isPublic?: boolean; }; export const toQueryKey = (query: { apiUrl: string; path: (string | undefined)[]; params?: { [query: string]: boolean | number | string | string[] | undefined }; }) => { return [ cleanSlash(query.apiUrl, true), ...query.path, query.params ? `?${Object.entries(query.params) .filter(([_, v]) => v !== undefined) .map(([k, v]) => `${k}=${Array.isArray(v) ? v.join(",") : v}`) .join("&")}` : null, ].filter((x) => x); }; export const useFetch = (query: QueryIdentifier) => { const { apiUrl, authToken } = useContext(AccountContext); const key = toQueryKey({ apiUrl, path: query.path, params: query.params }); return useQuery({ queryKey: key, queryFn: (ctx) => queryFn({ url: key.join("/").replace("/?", "?"), parser: query.parser, signal: ctx.signal, authToken: authToken?.access_token ?? null, ...query.options, }), placeholderData: query.placeholderData as any, enabled: query.enabled, }); }; export const useInfiniteFetch = (query: QueryIdentifier) => { const { apiUrl, authToken } = useContext(AccountContext); const key = toQueryKey({ apiUrl, path: query.path, params: query.params }); const ret = useInfiniteQuery, KyooError>({ queryKey: key, queryFn: (ctx) => queryFn({ url: (ctx.pageParam as string) ?? key.join("/").replace("/?", "?"), parser: Paged(query.parser), signal: ctx.signal, authToken: authToken?.access_token ?? null, ...query.options, }), getNextPageParam: (page: Page) => page?.next || undefined, initialPageParam: undefined, placeholderData: query.placeholderData as any, enabled: query.enabled, }); const items = ret.data?.pages.flatMap((x) => x.items); return { ...ret, items: items && typeof query.infinite === "object" && query.infinite.map ? query.infinite.map(items) : (items as unknown as Ret[] | undefined), }; }; export const prefetch = async (...queries: QueryIdentifier[]) => { const client = createQueryClient(); const authToken = undefined; await Promise.all( queries.map((query) => { const key = toQueryKey({ apiUrl: "/api", path: query.path, params: query.params }); if (query.infinite) { return client.prefetchInfiniteQuery({ queryKey: key, queryFn: (ctx) => queryFn({ url: key.join("/").replace("/?", "?"), parser: Paged(query.parser), signal: ctx.signal, authToken: authToken?.access_token ?? null, ...query.options, }), initialPageParam: undefined, }); } return client.prefetchQuery({ queryKey: key, queryFn: (ctx) => queryFn({ url: key.join("/").replace("/?", "?"), parser: query.parser, signal: ctx.signal, authToken: authToken?.access_token ?? null, ...query.options, }), }); }), ); setServerData("queryState", dehydrate(client)); return client; };