UNPKG

kitcn

Version:

kitcn - React Query integration and CLI tools for Convex

1,555 lines (1,539 loc) 85.4 kB
'use client'; import { D as CRPCClientError, E as writeAuthSessionFallbackToken, O as defaultIsUnauthorized, S as clearAuthSessionFallback, T as writeAuthSessionFallbackData, _ as useConvexAuthBridge, a as ConvexProviderWithAuth, b as useMaybeAuth, c as MaybeUnauthenticated, d as isSessionSyncGraceActive, f as useAuth, g as useAuthValue, h as useAuthStore, i as ConvexAuthBridge, k as isCRPCClientError, l as Unauthenticated, m as useAuthState, n as AuthProvider, o as FetchAccessTokenContext, p as useAuthGuard, r as Authenticated, s as MaybeAuthenticated, t as AUTH_SESSION_SYNC_GRACE_MS, u as decodeJwtExp, v as useFetchAccessToken, x as useSafeConvexAuth, y as useIsAuth } from "../auth-store-CwGbvP_s.js"; import { ConvexProvider, ConvexReactClient, ConvexReactClient as ConvexReactClient$1, useAction, useConvex, useMutation } from "convex/react"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { jsx } from "react/jsx-runtime"; import { getFunctionName } from "convex/server"; import { notifyManager, skipToken, useQueries, useQueryClient } from "@tanstack/react-query"; import { hashKey } from "@tanstack/query-core"; import { convexToJson } from "convex/values"; import { ConvexHttpClient } from "convex/browser"; //#region src/shared/meta-utils.ts const metaCache = /* @__PURE__ */ new WeakMap(); const nonMetaLeafKeys = new Set(["functionRef", "ref"]); function isRecord(value) { return typeof value === "object" && value !== null; } function isFunctionType(value) { return value === "query" || value === "mutation" || value === "action"; } function isMetaScalar(value) { return typeof value === "string" || typeof value === "number" || typeof value === "boolean"; } function extractLeafMeta(value) { const type = value.type; if (!isFunctionType(type)) return; const result = { type }; for (const [key, entry] of Object.entries(value)) { if (key === "type" || nonMetaLeafKeys.has(key) || key.startsWith("_")) continue; if (entry === void 0) continue; if (isMetaScalar(entry)) result[key] = entry; } return result; } function getHttpRoutes(api) { const routes = api._http; if (!isRecord(routes)) return; const normalized = {}; for (const [routeKey, routeValue] of Object.entries(routes)) { if (!isRecord(routeValue)) continue; const routePath = routeValue.path; const routeMethod = routeValue.method; if (typeof routePath === "string" && typeof routeMethod === "string") normalized[routeKey] = { path: routePath, method: routeMethod }; } return normalized; } /** * Build a metadata index from merged API leaves. * Supports both generated `api` objects and plain metadata fixtures. */ function buildMetaIndex(api) { const cached = metaCache.get(api); if (cached) return cached; const meta = {}; const httpRoutes = getHttpRoutes(api); if (httpRoutes) meta._http = httpRoutes; const walk = (node, path) => { for (const [key, value] of Object.entries(node)) { if (key.startsWith("_")) continue; if (!isRecord(value)) continue; const leafMeta = extractLeafMeta(value); if (leafMeta) { if (path.length === 0) continue; const namespace = path.join("/"); meta[namespace] ??= {}; meta[namespace][key] = leafMeta; continue; } walk(value, [...path, key]); } }; walk(api, []); metaCache.set(api, meta); return meta; } /** * Get a function reference from the API object by traversing the path. */ function getFuncRef(api, path) { let current = api; for (const key of path) if (current && typeof current === "object") { const next = current[key]; if (next === void 0) throw new Error(`Invalid path: ${path.join(".")}`); current = next; } else throw new Error(`Invalid path: ${path.join(".")}`); if (current && typeof current === "object") { const maybeFunctionRef = current.functionRef; if (maybeFunctionRef && typeof maybeFunctionRef === "object") return maybeFunctionRef; } if (!current || typeof current !== "object") throw new Error(`Invalid function reference at path: ${path.join(".")}`); return current; } /** * Get function type from meta using path. * Supports nested paths like ['items', 'queries', 'list'] → namespace='items/queries', fn='list' * * @param path - Path segments like ['todos', 'create'] or ['items', 'queries', 'list'] * @param meta - The meta object from codegen * @returns Function type or 'query' as default */ function getFunctionType(path, source) { if (path.length < 2) return "query"; const meta = buildMetaIndex(source); const fnName = path.at(-1); const fnType = meta[path.slice(0, -1).join("/")]?.[fnName]?.type; if (fnType === "query" || fnType === "mutation" || fnType === "action") return fnType; return "query"; } /** * Get function metadata from meta using path. * Supports nested paths like ['items', 'queries', 'list'] → namespace='items/queries', fn='list' * * @param path - Path segments like ['todos', 'create'] or ['items', 'queries', 'list'] * @param meta - The meta object from codegen * @returns Function metadata or undefined */ function getFunctionMeta(path, source) { if (path.length < 2) return; const meta = buildMetaIndex(source); const fnName = path.at(-1); return meta[path.slice(0, -1).join("/")]?.[fnName]; } //#endregion //#region src/crpc/http-types.ts /** HTTP client error */ var HttpClientError = class extends Error { name = "HttpClientError"; code; status; procedureName; constructor(opts) { super(opts.message ?? `${opts.code}: ${opts.procedureName}`); this.code = opts.code; this.status = opts.status; this.procedureName = opts.procedureName; } }; //#endregion //#region src/crpc/http-client.ts /** * HTTP Client Helpers * * Framework-agnostic utilities for executing HTTP requests * against Convex HTTP endpoints. */ /** Reserved keys that are not part of JSON body */ const RESERVED_KEYS = new Set([ "params", "searchParams", "form", "fetch", "init", "headers" ]); /** * Replace URL path parameters with actual values. * e.g., '/users/:id' with { id: '123' } -> '/users/123' */ function replaceUrlParam(url, params) { return url.replace(/:(\w+)/g, (_, key) => { const value = params[key]; return value !== void 0 ? encodeURIComponent(value) : `:${key}`; }); } /** * Build URLSearchParams from query object. * Handles array values as multiple params with same key (like Hono). */ function buildSearchParams(query) { const params = new URLSearchParams(); for (const [key, value] of Object.entries(query)) if (Array.isArray(value)) for (const v of value) params.append(key, v); else if (value !== void 0 && value !== null) params.append(key, value); return params; } /** * Hono-style HTTP request executor. * Processes args in the same way as Hono's ClientRequestImpl.fetch(). */ async function executeHttpRequest(opts) { const { method, path } = opts.route; const args = opts.args ?? {}; let rBody; let cType; if (args.form) { const form = new FormData(); for (const [k, v] of Object.entries(args.form)) if (Array.isArray(v)) for (const v2 of v) form.append(k, v2); else form.append(k, v); rBody = form; } else { const jsonBody = {}; for (const [key, value] of Object.entries(args)) if (!RESERVED_KEYS.has(key) && value !== void 0) jsonBody[key] = value; if (Object.keys(jsonBody).length > 0) { rBody = JSON.stringify(opts.transformer.input.serialize(jsonBody)); cType = "application/json"; } } const argsClientOpts = {}; if (args.fetch) argsClientOpts.fetch = args.fetch; if (args.init) argsClientOpts.init = args.init; if (args.headers) argsClientOpts.headers = args.headers; const mergedClientOpts = { ...opts.clientOpts, ...argsClientOpts }; const resolvedBaseHeaders = typeof opts.baseHeaders === "function" ? await opts.baseHeaders() : opts.baseHeaders; const headerValues = { ...typeof mergedClientOpts.headers === "function" ? await mergedClientOpts.headers() : mergedClientOpts.headers }; if (cType) headerValues["Content-Type"] = cType; const finalHeaders = {}; if (resolvedBaseHeaders) { for (const [key, value] of Object.entries(resolvedBaseHeaders)) if (value !== void 0) finalHeaders[key] = value; } Object.assign(finalHeaders, headerValues); let url = opts.convexSiteUrl + path; if (args.params) url = opts.convexSiteUrl + replaceUrlParam(path, args.params); if (args.searchParams) { const queryString = buildSearchParams(args.searchParams).toString(); if (queryString) url = `${url}?${queryString}`; } const methodUpperCase = method.toUpperCase(); const setBody = !(methodUpperCase === "GET" || methodUpperCase === "HEAD"); const response = await (mergedClientOpts.fetch ?? opts.baseFetch ?? globalThis.fetch)(url, { body: setBody ? rBody : void 0, method: methodUpperCase, headers: finalHeaders, ...mergedClientOpts.init }); if (!response.ok) { const errorData = await response.json().catch(() => ({ error: { code: "UNKNOWN", message: response.statusText } })); const errorCode = errorData?.error?.code || "UNKNOWN"; const errorMessage = errorData?.error?.message || response.statusText; throw new HttpClientError({ code: errorCode, status: response.status, procedureName: opts.procedureName, message: errorMessage }); } if (response.headers.get("content-length") === "0" || response.status === 204) return; if ((response.headers.get("content-type") || "").includes("application/json")) return opts.transformer.output.deserialize(await response.json()); return response.text(); } //#endregion //#region src/crpc/transformer.ts const isPlainObject = (value) => { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const prototype = Object.getPrototypeOf(value); return prototype === Object.prototype || prototype === null; }; const CODEC_MARKER_KEY = "__crpc"; const CODEC_MARKER_VALUE = 1; const CODEC_TAG_KEY = "t"; const CODEC_VALUE_KEY = "v"; const hasOnlyCodecPayloadKeys = (value) => { let keyCount = 0; for (const key in value) { if (!Object.hasOwn(value, key)) continue; keyCount += 1; if (key !== CODEC_MARKER_KEY && key !== CODEC_TAG_KEY && key !== CODEC_VALUE_KEY) return false; if (keyCount > 3) return false; } return keyCount === 3; }; /** * Date wire tag (Convex-style reserved key). */ const DATE_CODEC_TAG = "$date"; /** * Built-in Date codec. */ const dateWireCodec = { tag: DATE_CODEC_TAG, isType: (value) => value instanceof Date, encode: (value) => value.getTime(), decode: (value) => { if (typeof value !== "number") return value; return new Date(value); } }; /** * Build a recursive tagged transformer from codecs. */ const createTaggedTransformer = (codecs) => { const codecByTag = /* @__PURE__ */ new Map(); for (const codec of codecs) { if (!codec.tag.startsWith("$")) throw new Error(`Invalid wire codec tag '${codec.tag}'. Tags must start with '$'.`); if (codecByTag.has(codec.tag)) throw new Error(`Duplicate wire codec tag '${codec.tag}'.`); codecByTag.set(codec.tag, codec); } const serialize = (value) => { for (const codec of codecs) if (codec.isType(value)) return { [CODEC_MARKER_KEY]: CODEC_MARKER_VALUE, [CODEC_TAG_KEY]: codec.tag, [CODEC_VALUE_KEY]: serialize(codec.encode(value)) }; if (Array.isArray(value)) { let result; for (let index = 0; index < value.length; index += 1) { const item = value[index]; const serialized = serialize(item); if (serialized !== item) { if (!result) result = value.slice(); result[index] = serialized; } } return result ?? value; } if (isPlainObject(value)) { let result; for (const key in value) { if (!Object.hasOwn(value, key)) continue; const nested = value[key]; const serialized = serialize(nested); if (serialized !== nested) { if (!result) result = { ...value }; result[key] = serialized; } } return result ?? value; } return value; }; const deserialize = (value) => { if (Array.isArray(value)) { let result; for (let index = 0; index < value.length; index += 1) { const item = value[index]; const deserialized = deserialize(item); if (deserialized !== item) { if (!result) result = value.slice(); result[index] = deserialized; } } return result ?? value; } if (isPlainObject(value)) { const marker = value[CODEC_MARKER_KEY]; const tag = value[CODEC_TAG_KEY]; if (marker === CODEC_MARKER_VALUE && typeof tag === "string" && CODEC_VALUE_KEY in value && hasOnlyCodecPayloadKeys(value)) { const codec = codecByTag.get(tag); if (codec) return codec.decode(deserialize(value[CODEC_VALUE_KEY])); } let result; for (const key in value) { if (!Object.hasOwn(value, key)) continue; const nested = value[key]; const deserialized = deserialize(nested); if (deserialized !== nested) { if (!result) result = { ...value }; result[key] = deserialized; } } return result ?? value; } return value; }; return { serialize, deserialize }; }; /** * Default cRPC transformer (Date-enabled). */ const defaultCRPCTransformer = createTaggedTransformer([dateWireCodec]); const DEFAULT_COMBINED_TRANSFORMER = { input: defaultCRPCTransformer, output: defaultCRPCTransformer }; /** * Normalize transformer config to split input/output shape. */ const normalizeCustomTransformer = (transformer) => { if (!transformer) return; if ("input" in transformer && "output" in transformer) return transformer; return { input: transformer, output: transformer }; }; /** * Compose user transformer with default Date transformer. * * Date transformer is always active: * - serialize: user -> default(Date) * - deserialize: default(Date) -> user */ const composeWithDefault = (transformer) => { if (!transformer) return defaultCRPCTransformer; return { serialize: (value) => defaultCRPCTransformer.serialize(transformer.serialize(value)), deserialize: (value) => transformer.deserialize(defaultCRPCTransformer.deserialize(value)) }; }; const transformerCache = /* @__PURE__ */ new WeakMap(); /** * Normalize transformer config to split input/output shape. * User transformers are additive and always composed with default Date handling. */ const getTransformer = (transformer) => { if (!transformer) return DEFAULT_COMBINED_TRANSFORMER; const cacheKey = transformer; const cached = transformerCache.get(cacheKey); if (cached) return cached; const custom = normalizeCustomTransformer(transformer); const resolved = { input: composeWithDefault(custom?.input), output: composeWithDefault(custom?.output) }; transformerCache.set(cacheKey, resolved); return resolved; }; //#endregion //#region src/react/http-proxy.ts /** * Create a recursive proxy for HTTP routes with TanStack Query integration. * * Terminal methods: * - GET endpoints: `queryOptions`, `queryKey` * - POST/PUT/PATCH/DELETE: `mutationOptions`, `mutationKey` */ function createRecursiveHttpProxy(opts, path = []) { return new Proxy(() => {}, { get(_, prop) { if (typeof prop === "symbol") return; if (prop === "then") return; const routeKey = path.join("."); const route = opts.routes[routeKey]; if (prop === "query") { if (!route) throw new Error(`Unknown HTTP procedure: ${routeKey}`); return async (args) => { try { return await executeHttpRequest({ convexSiteUrl: opts.convexSiteUrl, route, procedureName: routeKey, args, baseHeaders: opts.headers, baseFetch: opts.fetch, transformer: opts.transformer }); } catch (error) { if (opts.onError && error instanceof HttpClientError) opts.onError(error); throw error; } }; } if (prop === "mutate") { if (!route) throw new Error(`Unknown HTTP procedure: ${routeKey}`); return async (args) => { try { return await executeHttpRequest({ convexSiteUrl: opts.convexSiteUrl, route, procedureName: routeKey, args, baseHeaders: opts.headers, baseFetch: opts.fetch, transformer: opts.transformer }); } catch (error) { if (opts.onError && error instanceof HttpClientError) opts.onError(error); throw error; } }; } if (prop === "queryOptions") { if (!route) throw new Error(`Unknown HTTP procedure: ${routeKey}`); if (route.method !== "GET") throw new Error(`queryOptions is only available for GET endpoints, got ${route.method} for ${routeKey}`); return (args, queryOpts) => ({ ...queryOpts, queryKey: [ "httpQuery", routeKey, args ], queryFn: async () => { try { return await executeHttpRequest({ convexSiteUrl: opts.convexSiteUrl, route, procedureName: routeKey, args, baseHeaders: opts.headers, baseFetch: opts.fetch, transformer: opts.transformer }); } catch (error) { if (opts.onError && error instanceof HttpClientError) opts.onError(error); throw error; } } }); } if (prop === "queryKey") return (args) => { return args !== void 0 && !(typeof args === "object" && args !== null && Object.keys(args).length === 0) ? [ "httpQuery", routeKey, args ] : ["httpQuery", routeKey]; }; if (prop === "queryFilter") return (args, filters) => { const hasArgs = args !== void 0 && !(typeof args === "object" && args !== null && Object.keys(args).length === 0); return { ...filters, queryKey: hasArgs ? [ "httpQuery", routeKey, args ] : ["httpQuery", routeKey] }; }; if (prop === "mutationOptions") { if (!route) throw new Error(`Unknown HTTP procedure: ${routeKey}`); return (mutationOpts) => ({ ...mutationOpts, mutationKey: ["httpMutation", routeKey], mutationFn: async (args) => { try { return await executeHttpRequest({ convexSiteUrl: opts.convexSiteUrl, route, procedureName: routeKey, args, baseHeaders: opts.headers, baseFetch: opts.fetch, transformer: opts.transformer }); } catch (error) { if (opts.onError && error instanceof HttpClientError) opts.onError(error); throw error; } } }); } if (prop === "mutationKey") return () => ["httpMutation", routeKey]; return createRecursiveHttpProxy(opts, [...path, prop]); } }); } /** * Create an HTTP proxy with TanStack Query integration. * * Returns a proxy that provides: * - `queryOptions` for GET endpoints (no subscription) * - `mutationOptions` for POST/PUT/PATCH/DELETE endpoints * * @example * ```ts * const httpProxy = createHttpProxy<AppRouter>({ * convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!, * routes: httpRoutes, * }); * * // GET endpoint * const opts = httpProxy.todos.get.queryOptions({ id: '123' }); * const { data } = useQuery(opts); * * // POST endpoint * const mutation = useMutation(httpProxy.todos.create.mutationOptions()); * await mutation.mutateAsync({ title: 'New todo' }); * ``` */ function createHttpProxy(opts) { const transformer = getTransformer(opts.transformer); return createRecursiveHttpProxy({ convexSiteUrl: opts.convexSiteUrl, routes: opts.routes, headers: opts.headers, fetch: opts.fetch, onError: opts.onError, transformer }); } //#endregion //#region src/crpc/query-options.ts /** * Query options factory for Convex query function subscriptions. * Requires `convexQueryClient.queryFn()` set as the default `queryFn` globally. */ function convexQuery(funcRef, args, meta, opts) { const finalArgs = args ?? {}; const isSkip = finalArgs === "skip"; const funcName = getFunctionName(funcRef); const [namespace, fnName] = funcName.split(":"); const authType = meta?.[namespace]?.[fnName]?.auth; const skipUnauth = opts?.skipUnauth; return { queryKey: [ "convexQuery", funcName, isSkip ? "skip" : finalArgs ], staleTime: Number.POSITIVE_INFINITY, refetchInterval: false, refetchOnMount: false, refetchOnReconnect: false, refetchOnWindowFocus: false, ...isSkip ? { enabled: false } : {}, meta: { authType, skipUnauth, subscribe: true } }; } /** * Query options factory for Convex action functions. * Actions are NOT reactive - they follow normal TanStack Query semantics. * * @example * ```ts * useQuery(convexAction(api.ai.generate, { prompt })) * ``` * * @example With additional options (use spread): * ```ts * useQuery({ * ...convexAction(api.files.process, { fileId }), * staleTime: 60_000 * }); * ``` */ function convexAction(funcRef, args, meta, opts) { const finalArgs = args ?? {}; const isSkip = finalArgs === "skip"; const funcName = getFunctionName(funcRef); const [namespace, fnName] = funcName.split(":"); const authType = meta?.[namespace]?.[fnName]?.auth; const skipUnauth = opts?.skipUnauth; return { queryKey: [ "convexAction", funcName, isSkip ? {} : finalArgs ], staleTime: Number.POSITIVE_INFINITY, refetchInterval: false, refetchOnMount: false, refetchOnReconnect: false, refetchOnWindowFocus: false, ...isSkip ? { enabled: false } : {}, meta: { authType, skipUnauth, subscribe: false } }; } /** * Infinite query options factory for paginated Convex queries. * Server-safe (non-hook) - can be used in RSC. * * Uses flat { cursor, limit } input like tRPC. */ function convexInfiniteQueryOptions(funcRef, args, opts = {}, meta) { const { limit, skipUnauth, enabled, ...queryOptions } = opts; const finalArgs = args === "skip" ? {} : args; const isSkip = args === "skip"; const funcName = getFunctionName(funcRef); const [namespace, fnName] = funcName.split(":"); const authType = (meta?.[namespace]?.[fnName])?.auth; const firstPageArgs = { ...finalArgs, cursor: null, limit }; const finalEnabled = enabled === false || isSkip ? false : void 0; return { queryKey: [ "convexQuery", funcName, firstPageArgs ], staleTime: Number.POSITIVE_INFINITY, refetchInterval: false, refetchOnMount: false, refetchOnReconnect: false, refetchOnWindowFocus: false, ...queryOptions, ...finalEnabled === false ? { enabled: false } : {}, meta: { authType, skipUnauth, subscribe: true, queryName: funcName, args: finalArgs, limit } }; } //#endregion //#region src/crpc/types.ts /** Symbol key for attaching FunctionReference to options (non-serializable) */ const FUNC_REF_SYMBOL = Symbol.for("convex.funcRef"); //#endregion //#region src/internal/auth.ts /** Get auth type from meta for a function */ function getAuthType(meta, funcName) { const [namespace, fnName] = funcName.split(":"); return meta?.[namespace]?.[fnName]?.auth; } /** Hook to compute auth-based skip logic for queries */ function useAuthSkip(funcRef, opts) { const { isAuthenticated, isLoading: isAuthLoading } = useSafeConvexAuth(); const authType = getAuthType(useMeta(), getFunctionName(funcRef)); const authLoadingApplies = authType === "optional" || authType === "required"; return { authType, isAuthLoading, isAuthenticated, shouldSkip: opts?.enabled === false || authLoadingApplies && isAuthLoading || authType === "required" && !isAuthenticated && !isAuthLoading || !isAuthenticated && !isAuthLoading && !!opts?.skipUnauth }; } //#endregion //#region src/internal/query-key.ts /** * Shared query key utilities for Convex + TanStack Query. * This file has NO React dependencies so it can be imported in both * server (RSC) and client contexts. */ /** * Check if query key is for a Convex query function. * Format: ['convexQuery', 'namespace:functionName', { args }] */ function isConvexQuery(queryKey) { return queryKey.length >= 2 && queryKey[0] === "convexQuery"; } /** * Check if query key is for a Convex action function. * Format: ['convexAction', 'namespace:functionName', { args }] */ function isConvexAction$1(queryKey) { return queryKey.length >= 2 && queryKey[0] === "convexAction"; } /** * Create stable hash for Convex query keys. * Uses Convex's JSON serialization for consistent argument hashing. */ function hashConvexQuery(queryKey) { const [, funcName, args] = queryKey; return `convexQuery|${funcName}|${JSON.stringify(convexToJson(args))}`; } /** * Create stable hash for Convex action keys. * Uses Convex's JSON serialization for consistent argument hashing. */ function hashConvexAction(queryKey) { const [, funcName, args] = queryKey; return `convexAction|${funcName}|${JSON.stringify(convexToJson(args))}`; } //#endregion //#region src/internal/hash.ts /** * Create a hash function for TanStack Query that handles Convex keys. */ function createHashFn(fallback = hashKey) { return (queryKey) => { if (isConvexQuery(queryKey)) return hashConvexQuery(queryKey); if (isConvexAction$1(queryKey)) return hashConvexAction(queryKey); return fallback(queryKey); }; } //#endregion //#region src/react/use-query-options.ts /** biome-ignore-all lint/suspicious/noExplicitAny: Convex type compatibility */ /** * Query options factories for Convex functions. * Forked from @convex-dev/react-query to support auth-aware error handling. */ const EMPTY_ARGS = {}; const hashConvexOptionsKey = createHashFn(); const MAX_STABLE_ARGS = 500; const stableArgsByHash = /* @__PURE__ */ new Map(); function getStableArgsByHash(hash, args) { if (stableArgsByHash.has(hash)) { const stableArgs = stableArgsByHash.get(hash); stableArgsByHash.delete(hash); stableArgsByHash.set(hash, stableArgs); return stableArgs; } stableArgsByHash.set(hash, args); if (stableArgsByHash.size > MAX_STABLE_ARGS) { const oldestHash = stableArgsByHash.keys().next().value; if (oldestHash !== void 0) stableArgsByHash.delete(oldestHash); } return args; } function useStableQueryArgs(prefix, funcRef, args) { const resolvedArgs = args === skipToken || args == null ? EMPTY_ARGS : args; const argsHash = hashConvexOptionsKey([ prefix, getFunctionName(funcRef), resolvedArgs ]); const value = useMemo(() => getStableArgsByHash(argsHash, resolvedArgs), [argsHash, resolvedArgs]); return useMemo(() => ({ hash: argsHash, value }), [argsHash, value]); } /** * Hook that returns query options for use with useQuery. * Handles skipUnauth by setting enabled: false when unauthorized. * * @example * ```tsx * const { data } = useQuery(useConvexQueryOptions(api.user.get, { id })); * ``` * * @example With skipToken for conditional queries * ```tsx * const { data } = useQuery(useConvexQueryOptions(api.user.get, userId ? { id: userId } : skipToken)); * ``` * * @example With skipUnauth * ```tsx * const { data } = useQuery(useConvexQueryOptions(api.user.get, { id }, { skipUnauth: true })); * ``` * * @example With TanStack Query options * ```tsx * const { data } = useQuery(useConvexQueryOptions(api.user.get, { id }, { enabled: !!id, placeholderData: [] })); * ``` */ function useConvexQueryOptions(funcRef, args, options) { const isSkipped = args === skipToken; const enabled = typeof options?.enabled === "function" ? void 0 : options?.enabled; const { authType, shouldSkip } = useAuthSkip(funcRef, { enabled: isSkipped ? false : enabled, skipUnauth: options?.skipUnauth }); const stableArgs = useStableQueryArgs("convexQuery", funcRef, isSkipped ? EMPTY_ARGS : args); const baseOptions = useMemo(() => convexQuery(funcRef, stableArgs.value), [funcRef, stableArgs]); return useMemo(() => { const { skipUnauth: _, subscribe, ...queryOptions } = options ?? {}; return { ...baseOptions, ...queryOptions, enabled: isSkipped ? false : !shouldSkip, meta: { ...baseOptions.meta, authType, subscribe: subscribe !== false } }; }, [ authType, baseOptions, isSkipped, options, shouldSkip ]); } /** * Hook that returns infinite query options for use with useInfiniteQuery. * Handles auth type detection from meta and skipUnauth. * * @example * ```tsx * const { data } = useInfiniteQuery( * useConvexInfiniteQueryOptions(api.posts.list, { userId }, { limit: 20 }) * ); * ``` * * @example With skipToken for conditional queries * ```tsx * const { data } = useInfiniteQuery( * useConvexInfiniteQueryOptions(api.posts.list, userId ? { userId } : skipToken, { limit: 20 }) * ); * ``` * * @example With skipUnauth * ```tsx * const { data } = useInfiniteQuery( * useConvexInfiniteQueryOptions(api.posts.list, { userId }, { limit: 20, skipUnauth: true }) * ); * ``` */ function useConvexInfiniteQueryOptions(funcRef, args, opts) { const meta = useMeta(); const isSkipped = args === skipToken; const enabledOpt = typeof opts.enabled === "function" ? void 0 : opts.enabled; const { authType, shouldSkip } = useAuthSkip(funcRef, { enabled: isSkipped ? false : enabledOpt, skipUnauth: opts.skipUnauth }); const enabled = isSkipped || shouldSkip ? false : enabledOpt; const baseOptions = convexInfiniteQueryOptions(funcRef, isSkipped ? {} : args, { ...opts, enabled }, meta); return { ...baseOptions, meta: { ...baseOptions.meta, authType } }; } /** * Hook that returns query options for using an action as a one-shot query. * Actions don't support WebSocket subscriptions - they're one-time calls. * * @example * ```tsx * const { data } = useQuery(useConvexActionQueryOptions(api.ai.analyze, { id })); * ``` * * @example With skipToken for conditional queries * ```tsx * const { data } = useQuery(useConvexActionQueryOptions(api.ai.analyze, id ? { id } : skipToken)); * ``` * * @example With skipUnauth * ```tsx * const { data } = useQuery(useConvexActionQueryOptions(api.ai.analyze, { id }, { skipUnauth: true })); * ``` */ function useConvexActionQueryOptions(action, args, options) { const isSkipped = args === skipToken; const enabled = typeof options?.enabled === "function" ? void 0 : options?.enabled; const { shouldSkip } = useAuthSkip(action, { enabled: isSkipped ? false : enabled, skipUnauth: options?.skipUnauth }); const stableArgs = useStableQueryArgs("convexAction", action, isSkipped ? EMPTY_ARGS : args); const baseOptions = useMemo(() => convexAction(action, stableArgs.value), [action, stableArgs]); return useMemo(() => { const { skipUnauth: _, ...queryOptions } = options ?? {}; return { ...baseOptions, ...queryOptions, enabled: isSkipped ? false : !shouldSkip }; }, [ baseOptions, isSkipped, options, shouldSkip ]); } /** * Hook that returns mutation options for use with useMutation. * Wraps the Convex mutation with auth guard logic. * * @example * ```tsx * const { mutate } = useMutation(useConvexMutationOptions(api.user.update)); * ``` * * @example With TanStack Query options * ```tsx * const { mutate } = useMutation(useConvexMutationOptions(api.user.update, { * onSuccess: () => toast.success('Updated!'), * })); * ``` */ function useConvexMutationOptions(mutation, options, transformer) { const guard = useAuthGuard(); const getMeta = useFnMeta(); const name = getFunctionName(mutation); const [namespace, fnName] = name.split(":"); const authType = getMeta(namespace, fnName)?.auth; const convexMutation = useMutation(mutation); const resolvedTransformer = getTransformer(transformer); return { ...options, mutationFn: async (args) => { if (authType === "required" && guard()) throw new CRPCClientError({ code: "UNAUTHORIZED", functionName: name }); return convexMutation(resolvedTransformer.input.serialize(args)); } }; } /** * Hook that returns action options for use with useMutation. * Wraps the Convex action with auth guard logic. * * @example * ```tsx * const { mutate } = useMutation(useConvexActionOptions(api.ai.generate)); * ``` * * @example With TanStack Query options * ```tsx * const { mutate } = useMutation(useConvexActionOptions(api.ai.generate, { * onSuccess: (data) => console.info(data), * })); * ``` */ function useConvexActionOptions(action, options, transformer) { const guard = useAuthGuard(); const getMeta = useFnMeta(); const name = getFunctionName(action); const [namespace, fnName] = name.split(":"); const authType = getMeta(namespace, fnName)?.auth; const convexAction = useAction(action); const resolvedTransformer = getTransformer(transformer); return { ...options, mutationFn: async (args) => { if (authType === "required" && guard()) throw new CRPCClientError({ code: "UNAUTHORIZED", functionName: name }); return convexAction(resolvedTransformer.input.serialize(args)); } }; } /** * Hook that returns upload mutation options for use with useMutation. * Generates a presigned URL, then uploads the file directly to storage. * * @example * ```tsx * const { mutate } = useMutation(useUploadMutationOptions(api.storage.generateUrl)); * mutate({ file, ...otherArgs }); * ``` * * @example With TanStack Query options * ```tsx * const { mutate } = useMutation(useUploadMutationOptions(api.storage.generateUrl, { * onSuccess: (result) => console.info('Uploaded:', result.key), * })); * ``` */ function useUploadMutationOptions(generateUrlMutation, options) { const generateUrl = useMutation(generateUrlMutation); return { ...options, mutationFn: async ({ file, ...args }) => { const result = await generateUrl(args); const { url } = result; const response = await fetch(url, { body: file, headers: { "Content-Type": file.type }, method: "PUT" }); if (!response.ok) throw new Error(`Upload failed: ${response.statusText}`); return result; } }; } //#endregion //#region src/react/proxy.ts /** * CRPC Recursive Proxy * * Creates a tRPC-like proxy that wraps Convex API functions with * TanStack Query options builders. * * @example * ```ts * const crpc = createCRPCOptionsProxy(api); * const opts = crpc.user.get.queryOptions({ id: '123' }); * const { data } = useQuery(opts); * ``` */ /** Get query key prefix based on function type */ function getQueryKeyPrefix(path, meta) { if (getFunctionType(path, meta) === "action") return "convexAction"; return "convexQuery"; } /** * Create a recursive proxy that accumulates path segments. */ function createRecursiveProxy(api, path, meta, transformer) { return new Proxy(() => {}, { get(_target, prop) { if (typeof prop === "symbol") return; if (prop === "then") return; if (prop === "queryOptions") return (args = {}, opts) => { const funcRef = getFuncRef(api, path); if (getFunctionType(path, meta) === "action") return useConvexActionQueryOptions(funcRef, args, opts); return useConvexQueryOptions(funcRef, args, opts); }; if (prop === "staticQueryOptions") return (args = {}, opts) => { const funcRef = getFuncRef(api, path); const fnType = getFunctionType(path, meta); const finalArgs = args === skipToken ? "skip" : args; if (fnType === "action") return convexAction(funcRef, finalArgs, meta, opts); return convexQuery(funcRef, finalArgs, meta, opts); }; if (prop === "queryKey") return (args = {}) => { const funcName = getFunctionName(getFuncRef(api, path)); return [ getQueryKeyPrefix(path, meta), funcName, args ]; }; if (prop === "queryFilter") return (args, filters) => { const funcName = getFunctionName(getFuncRef(api, path)); const prefix = getQueryKeyPrefix(path, meta); return { ...filters, queryKey: [ prefix, funcName, args ] }; }; if (prop === "infiniteQueryOptions") return (args = {}, opts = {}) => { const funcRef = getFuncRef(api, path); const options = useConvexInfiniteQueryOptions(funcRef, args, opts); Object.defineProperty(options, FUNC_REF_SYMBOL, { value: funcRef, enumerable: false, configurable: false }); return options; }; if (prop === "infiniteQueryKey") return (args) => { return [ "convexQuery", getFunctionName(getFuncRef(api, path)), args ?? {} ]; }; if (prop === "meta" && path.length >= 2) return getFunctionMeta(path, meta); if (prop === "mutationKey") return () => { return ["convexMutation", getFunctionName(getFuncRef(api, path))]; }; if (prop === "mutationOptions") return (opts) => { const funcRef = getFuncRef(api, path); if (getFunctionType(path, meta) === "action") return useConvexActionOptions(funcRef, opts, transformer); return useConvexMutationOptions(funcRef, opts, transformer); }; return createRecursiveProxy(api, [...path, prop], meta, transformer); } }); } /** * Create a CRPC proxy for a Convex API object. * * The proxy provides a tRPC-like interface for accessing Convex functions * with TanStack Query options builders. * * @param api - The Convex API object (from `@convex/api`) * @param meta - Generated function metadata for runtime type detection * @returns A typed proxy with queryOptions/mutationOptions methods * * @example * ```tsx * import { api } from '@convex/api'; * * // Usually you should use createCRPCContext({ api }) instead. * // createCRPCOptionsProxy is a low-level helper. * const crpc = createCRPCOptionsProxy(api, {} as any); * * function MyComponent() { * const { data } = useQuery(crpc.user.get.queryOptions({ id })); * const { mutate } = useMutation(crpc.user.update.mutationOptions()); * } * ``` */ function createCRPCOptionsProxy(api, meta, transformer) { return createRecursiveProxy(api, [], meta, transformer); } //#endregion //#region src/react/vanilla-client.ts /** * Create a recursive proxy for vanilla (direct) calls. */ function createRecursiveVanillaProxy(api, path, meta, convexClient, transformer) { return new Proxy(() => {}, { get(_target, prop) { if (typeof prop === "symbol") return; if (prop === "then") return; if (prop === "query") return async (args = {}) => { const funcRef = getFuncRef(api, path); const fnType = getFunctionType(path, meta); const wireArgs = transformer.input.serialize(args); if (fnType === "action") return transformer.output.deserialize(await convexClient.action(funcRef, wireArgs)); return transformer.output.deserialize(await convexClient.query(funcRef, wireArgs)); }; if (prop === "watchQuery") return (args = {}, opts) => { const funcRef = getFuncRef(api, path); return convexClient.watchQuery(funcRef, transformer.input.serialize(args), opts); }; if (prop === "mutate") return async (args = {}) => { const funcRef = getFuncRef(api, path); const fnType = getFunctionType(path, meta); const wireArgs = transformer.input.serialize(args); if (fnType === "action") return transformer.output.deserialize(await convexClient.action(funcRef, wireArgs)); return transformer.output.deserialize(await convexClient.mutation(funcRef, wireArgs)); }; return createRecursiveVanillaProxy(api, [...path, prop], meta, convexClient, transformer); } }); } /** * Create a vanilla CRPC proxy for direct procedural calls. * * The proxy provides a tRPC-like interface for imperative Convex function calls. * * @param api - The Convex API object (from `@convex/api`) * @param meta - Generated function metadata for runtime type detection * @param convexClient - The ConvexReactClient instance * @returns A typed proxy with query/mutate methods * * @example * ```tsx * const client = createVanillaCRPCProxy(api, meta, convexClient); * * // Direct calls (no React Query) * const user = await client.user.get.query({ id }); * await client.user.update.mutate({ id, name: 'test' }); * ``` */ function createVanillaCRPCProxy(api, meta, convexClient, transformer) { return createRecursiveVanillaProxy(api, [], meta, convexClient, getTransformer(transformer)); } //#endregion //#region src/react/context.tsx const ConvexQueryClientContext = createContext(null); /** Access ConvexQueryClient (e.g., for logout cleanup) */ const useConvexQueryClient = () => useContext(ConvexQueryClientContext); const MetaContext = createContext(void 0); /** * Hook to access the meta object from context. * Returns undefined if meta was not provided to createCRPCContext. */ function useMeta() { return useContext(MetaContext); } /** * Hook to get auth type for a function from meta. */ function useFnMeta() { const meta = useMeta(); return (namespace, fnName) => meta?.[namespace]?.[fnName]; } /** * Create CRPC context, provider, and hooks for a Convex API. * * @param options - Configuration object containing api and optional HTTP settings * @returns Object with CRPCProvider, useCRPC, and useCRPCClient * * @example * ```tsx * // lib/crpc.ts * import { api } from '@convex/api'; * import { createCRPCContext } from 'kitcn/react'; * * // Works for both regular Convex functions and generated HTTP router types * export const { useCRPC } = createCRPCContext({ * api, * convexSiteUrl: process.env.NEXT_PUBLIC_CONVEX_SITE_URL!, * }); * * // components/user-profile.tsx * function UserProfile({ id }) { * const crpc = useCRPC(); * const { data } = useQuery(crpc.user.get.queryOptions({ id })); * * // HTTP endpoints (if configured) * const { data: httpData } = useQuery(crpc.http.todos.get.queryOptions({ id })); * } * ``` */ function createCRPCContext(options) { const { api, ...httpOptions } = options; const meta = buildMetaIndex(api); const CRPCProxyContext = createContext(null); const VanillaClientContext = createContext(null); const HttpProxyContext = createContext(void 0); /** Inner provider */ function CRPCProviderInner({ children, convexClient, convexQueryClient }) { const authStore = useAuthStore(); const token = useAuthValue("token"); const isAuthenticated = useAuthValue("isAuthenticated"); const previousAuthRef = useRef(null); const fetchAccessToken = useFetchAccessToken(); useEffect(() => { const previous = previousAuthRef.current; const tokenReady = token === null || decodeJwtExp(token) !== null; previousAuthRef.current = { isAuthenticated, token }; if (!previous) return; if (tokenReady && (previous.token !== token || previous.isAuthenticated !== isAuthenticated)) convexQueryClient.resetAuthQueries(); }, [ convexQueryClient, isAuthenticated, token ]); const httpProxy = useMemo(() => { if (!httpOptions.convexSiteUrl || !meta._http) return; return createHttpProxy({ convexSiteUrl: httpOptions.convexSiteUrl, routes: meta._http, headers: async () => { const token = authStore.get("token"); const expiresAt = authStore.get("expiresAt"); const timeRemaining = expiresAt ? expiresAt - Date.now() : 0; if (token && expiresAt && timeRemaining >= 6e4) return { ...typeof httpOptions.headers === "function" ? await httpOptions.headers() : httpOptions.headers, Authorization: `Bearer ${token}` }; if (fetchAccessToken) { const newToken = await fetchAccessToken({ forceRefreshToken: !!expiresAt }); if (newToken) return { ...typeof httpOptions.headers === "function" ? await httpOptions.headers() : httpOptions.headers, Authorization: `Bearer ${newToken}` }; } return { ...typeof httpOptions.headers === "function" ? await httpOptions.headers() : httpOptions.headers }; }, fetch: httpOptions.fetch, onError: httpOptions.onError, transformer: options.transformer }); }, [authStore, fetchAccessToken]); const proxy = useMemo(() => createCRPCOptionsProxy(api, meta, options.transformer), []); const vanillaClient = useMemo(() => createVanillaCRPCProxy(api, meta, convexClient, options.transformer), [convexClient]); return /* @__PURE__ */ jsx(ConvexQueryClientContext.Provider, { value: convexQueryClient, children: /* @__PURE__ */ jsx(MetaContext.Provider, { value: meta, children: /* @__PURE__ */ jsx(VanillaClientContext.Provider, { value: vanillaClient, children: /* @__PURE__ */ jsx(HttpProxyContext.Provider, { value: httpProxy, children: /* @__PURE__ */ jsx(CRPCProxyContext.Provider, { value: proxy, children }) }) }) }) }); } /** * Provider component that wraps the app with CRPC context. * For auth, wrap with ConvexAuthProvider (or AuthProvider) above this. */ function CRPCProvider({ children, convexClient, convexQueryClient }) { return /* @__PURE__ */ jsx(CRPCProviderInner, { convexClient, convexQueryClient, children }); } /** * Hook to access the CRPC proxy for building query/mutation options. * * @returns The typed CRPC proxy (with http namespace if configured) * @throws If used outside of CRPCProvider * * @example * ```tsx * const crpc = useCRPC(); * const { data } = useQuery(crpc.user.get.queryOptions({ id })); * * // HTTP endpoints (if configured) * const { data: httpData } = useQuery(crpc.http.todos.get.queryOptions({ id })); * ``` */ function useCRPC() { const ctx = useContext(CRPCProxyContext); const httpProxy = useContext(HttpProxyContext); if (!ctx) throw new Error("useCRPC must be used within CRPCProvider"); if (httpProxy) return new Proxy(ctx, { get(target, prop) { if (prop === "http") return httpProxy; return Reflect.get(target, prop); } }); return ctx; } /** * Hook to access the vanilla CRPC client for direct procedural calls. * * @returns The typed VanillaCRPCClient for direct .query()/.mutate() calls * @throws If used outside of CRPCProvider * * @example * ```tsx * const client = useCRPCClient(); * * // Direct calls (no React Query) * const user = await client.user.get.query({ id }); * await client.user.update.mutate({ id, name: 'test' }); * * // HTTP endpoints (if configured) * const todos = await client.http.todos.list.queryOptions({}); * ``` */ function useCRPCClient() { const ctx = useContext(VanillaClientContext); const httpProxy = useContext(HttpProxyContext); if (!ctx) throw new Error("useCRPCClient must be used within CRPCProvider"); if (httpProxy) return new Proxy(ctx, { get(target, prop) { if (prop === "http") return httpProxy; return Reflect.get(target, prop); } }); return ctx; } return { CRPCProvider, useCRPC, useCRPCClient }; } //#endregion //#region src/crpc/auth-error.ts /** * Auth Mutation Error * * Framework-agnostic error class for Better Auth mutations. */ /** * Error thrown when a Better Auth mutation fails. * Contains the original error details from Better Auth. */ var AuthMutationError = class extends Error { /** Error code from Better Auth (e.g., 'INVALID_PASSWORD', 'EMAIL_ALREADY_REGISTERED') */ code; /** HTTP status code */ status; /** HTTP status text */ statusText; constructor(authError) { super(authError.message || authError.statusText); this.name = "AuthMutationError"; this.code = authError.code; this.status = authError.status; this.statusText = authError.statusText; } }; /** * Type guard to check if an error is an AuthMutationError. */ function isAuthMutationError(error) { return error instanceof AuthMutationError; } //#endregion //#region src/react/auth-mutations.ts /** Poll until JWT token exists (auth complete) (max 5s) */ const waitForAuth = async (store, timeout = 5e3) => { const start = Date.now(); while (Date.now() - start < timeout) { if (store.get("token")) return true; await new Promise((r) => setTimeout(r, 50)); } return false; }; const authStateTimeoutError = () => new AuthMutationError({ code: "AUTH_STATE_TIMEOUT", message: "Authentication did not complete. Try again.", status: 401, statusText: "UNAUTHORIZED" }); const ensureAuth = async (store) => { if (await waitForAuth(store)) return; throw authStateTimeoutError(); }; const readReturnedToken = (value) => { if (!value || typeof value !== "object") return null; const record = value; if (typeof record.token === "string" && record.token.length > 0) return record.token; return readReturnedToken(record.data) ?? readReturnedToken(record.session); }; const seedReturnedToken = (store, value) => { const token = readReturnedToken(value); if (!token) return; store.set("token", token); store.set("expiresAt", decodeJwtExp(token)); store.set("sessionSyncGraceUntil", Date.now() + AUTH_SESSION_SYNC_GRACE_MS); if (decodeJwtExp(token) === null) writeAuthSessionFallbackToken(token); }; const toAuthMutationError = (error) => new AuthMutationError({ code: error?.code, message: error?.message, status: error?.status ?? 500, statusText: error?.statusText ?? "AUTH_ERROR" }); const callAuthMethod = async (method, ...args) => { return method(...args); }; const syncSessionAtom = (authClient, sessionData) => { const sessionAtom = authClient.$store?.atoms?.session; if (typeof sessionAtom?.get !== "function" || typeof sessionAtom.set !== "function") return; const current = sessionAtom.get(); sessionAtom.set({ data: sessionData, error: null, isPending: false, isRefetching: false, refetch: current?.refetch ?? (async () => {}) }); }; const hydrateReturnedSession = async (authClient, value) => { const token = readReturnedToken(value); if (!token || typeof authClient.getSession !== "function") return; const session = await callAuthMethod(authClient.getSession, { fetchOptions: { credentials: "omit", headers: { Authorization: `Bearer ${token}` } } }); if (session?.data) { syncSessionAtom(authClient, session.data); writeAuthSessionFallbackData(session.data); } }; const withDisabledSessionSignal = (args) => { const record = args && typeof args === "object" ? args : {}; return { ...record, fetchOptions: { ...record.fetchOptions, disableSignal: true } }; }; function createAuthMutations(authClient) { const useSignOutMutationOptions = ((options) => { const convexQueryClient = useConvexQueryClient(); const authStoreApi = useAuthStore(); return { ...options, mutationFn: async (args) => { const signOut = authClient.signOut; if (typeof signOut !== "function") throw new Error("Auth client does not expose signOut"); authStoreApi.set("isAuthenticated", false); convexQueryClient?.unsubscribeAuthQueries(); const res = await callAuthMethod(signOut, args); if (res?.error) throw toAuthMutationError(res.error); authStoreApi.set("token", null); authStoreApi.set("expiresAt", null); authStoreApi.set("sessionSyncGraceUntil", null); clearAuthSessionFallback(); await convexQueryClient?.resetAuthQueries(); return res; } }; }); const useSignInSocialMutationOptions = ((options)