UNPKG

kitcn

Version:

kitcn - React Query integration and CLI tools for Convex

1,633 lines (1,617 loc) 94.2 kB
import { getFunctionName } from "convex/server"; import { Show, createContext, createEffect, createMemo, createSignal, on, onCleanup, onMount, useContext } from "solid-js"; import { createComponent, memo } from "solid-js/web"; import { createStore } from "solid-js/store"; import { notifyManager, skipToken, useQueries, useQueryClient } from "@tanstack/solid-query"; import { ConvexClient, ConvexHttpClient } from "convex/browser"; import { hashKey } from "@tanstack/query-core"; import { convexToJson } from "convex/values"; //#region src/crpc/error.ts /** * Client-side CRPC error. * Mirrors backend CRPCError pattern with typed error codes. */ var CRPCClientError = class extends Error { name = "CRPCClientError"; code; functionName; constructor(opts) { super(opts.message ?? `${opts.code}: ${opts.functionName}`); this.code = opts.code; this.functionName = opts.functionName; } }; /** Type guard for CRPCClientError */ const isCRPCClientError = (error) => error instanceof CRPCClientError; /** Default unauthorized detection - checks UNAUTHORIZED code */ const defaultIsUnauthorized = (error) => { if (!error || typeof error !== "object") return false; if ("data" in error) { const data = error.data; if (data && typeof data === "object" && "code" in data) return data.code === "UNAUTHORIZED"; } if ("code" in error) return error.code === "UNAUTHORIZED"; return false; }; //#endregion //#region src/solid/auth-store.tsx /** @jsxImportSource solid-js */ /** * Auth Store - Generic auth state management with Solid stores * * Provides token storage and auth callback configuration. * App configures handlers, lib hooks consume state. */ const FetchAccessTokenContext = createContext(null); /** Get fetchAccessToken from context (available immediately, no race condition) */ const useFetchAccessToken = () => useContext(FetchAccessTokenContext); /** * Context that holds auth result from ConvexAuthBridge. * Allows @convex-dev/auth users to use skipUnauth queries without better-auth. */ const ConvexAuthBridgeContext = createContext(null); /** Get auth from bridge context (null if no bridge configured) */ const useConvexAuthBridge = () => useContext(ConvexAuthBridgeContext); /** Decode JWT expiration (ms timestamp) from token */ function decodeJwtExp(token) { try { const payload = JSON.parse(atob(token.split(".")[1])); return payload.exp ? payload.exp * 1e3 : null; } catch { return null; } } const defaultState = { onMutationUnauthorized: () => { throw new CRPCClientError({ code: "UNAUTHORIZED", functionName: "mutation" }); }, onQueryUnauthorized: () => {}, isUnauthorized: defaultIsUnauthorized, token: null, expiresAt: null, isLoading: true, isAuthenticated: false }; const AuthStoreContext = createContext(null); function useAuthStore() { const ctx = useContext(AuthStoreContext); if (!ctx) return { get: (key) => defaultState[key], set: () => {}, store: null }; return ctx; } function useAuthValue(key) { return useAuthStore().get(key); } function AuthProvider(props) { const [state, setState] = createStore({ ...defaultState, ...props.initialValues, ...props.isUnauthorized && { isUnauthorized: props.isUnauthorized }, ...props.onMutationUnauthorized && { onMutationUnauthorized: props.onMutationUnauthorized }, ...props.onQueryUnauthorized && { onQueryUnauthorized: props.onQueryUnauthorized } }); const store = { get: (key) => state[key], set: (key, value) => setState(key, value), store: state }; return createComponent(AuthStoreContext.Provider, { value: store, get children() { return props.children; } }); } /** * Safe wrapper that doesn't throw when used outside auth provider. * Returns { isAuthenticated: false, isLoading: false } when no auth provider. * * Supports both: * - better-auth users (via AuthProvider) * - @convex-dev/auth users (via ConvexAuthBridge) */ function useSafeConvexAuth() { const authStore = useAuthStore(); const bridgeAuth = useConvexAuthBridge(); return { get isAuthenticated() { if (authStore.store) return authStore.get("isAuthenticated"); if (bridgeAuth !== null) return bridgeAuth.isAuthenticated; return false; }, get isLoading() { if (authStore.store) return authStore.get("isLoading"); if (bridgeAuth !== null) return bridgeAuth.isLoading; return false; } }; } /** * Bridge component that provides auth state via context. * @internal */ function ConvexAuthBridge(props) { return createComponent(ConvexAuthBridgeContext.Provider, { value: { get isLoading() { return props.isLoading; }, get isAuthenticated() { return props.isAuthenticated; } }, get children() { return props.children; } }); } const useAuth = () => { const authStore = useAuthStore(); const bridgeAuth = useConvexAuthBridge(); return { get hasSession() { if (authStore.store) return !!authStore.get("token"); return false; }, get isAuthenticated() { if (authStore.store) return authStore.get("isAuthenticated"); if (bridgeAuth !== null) return bridgeAuth.isAuthenticated; return false; }, get isLoading() { if (authStore.store) return authStore.get("isLoading"); if (bridgeAuth !== null) return bridgeAuth.isLoading; return false; } }; }; /** Check if user maybe has auth (optimistic, has token) */ const useMaybeAuth = () => { const auth = useAuth(); return () => auth.hasSession; }; /** Check if user is authenticated (server-verified) */ const useIsAuth = () => { const auth = useAuth(); return () => auth.isAuthenticated; }; const useAuthGuard = () => { const authStore = useAuthStore(); const bridgeAuth = useConvexAuthBridge(); return (callback) => { let isAuthenticated = false; if (authStore.store) isAuthenticated = authStore.get("isAuthenticated"); else if (bridgeAuth !== null) isAuthenticated = bridgeAuth.isAuthenticated; if (!isAuthenticated) { authStore.get("onMutationUnauthorized")(); return true; } return callback ? void callback() : false; }; }; /** Render children only when maybe has auth (optimistic) */ function MaybeAuthenticated(props) { const isAuth = useMaybeAuth(); return createComponent(Show, { get when() { return isAuth(); }, get children() { return props.children; } }); } /** Render children only when authenticated (server-verified) */ function Authenticated(props) { const isAuth = useIsAuth(); return createComponent(Show, { get when() { return isAuth(); }, get children() { return props.children; } }); } /** Render children only when maybe not auth (optimistic) */ function MaybeUnauthenticated(props) { const isAuth = useMaybeAuth(); return createComponent(Show, { get when() { return !isAuth(); }, get children() { return props.children; } }); } /** Render children only when not authenticated (server-verified) */ function Unauthenticated(props) { const auth = useAuth(); return createComponent(Show, { get when() { return memo(() => !!!auth.isAuthenticated)() && !auth.isLoading; }, get children() { return props.children; } }); } //#endregion //#region src/solid/auth.ts const MetaContext = createContext(void 0); /** * Hook to access the meta object from context. * Returns undefined if meta was not provided. */ function useMeta() { return useContext(MetaContext); } /** * Hook to get function metadata from the meta index. */ function useFnMeta() { const meta = useMeta(); return (namespace, fnName) => meta?.[namespace]?.[fnName]; } /** 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 auth = useSafeConvexAuth(); const authType = getAuthType(useMeta(), getFunctionName(funcRef)); const authLoadingApplies = authType === "optional" || authType === "required"; return { authType, get isAuthLoading() { return auth.isLoading; }, get isAuthenticated() { return auth.isAuthenticated; }, get shouldSkip() { const isAuthLoading = auth.isLoading; const isAuthenticated = auth.isAuthenticated; return opts?.enabled === false || authLoadingApplies && isAuthLoading || authType === "required" && !isAuthenticated && !isAuthLoading || !isAuthenticated && !isAuthLoading && !!opts?.skipUnauth; } }; } //#endregion //#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/solid/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 for SolidJS. * * 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: import.meta.env.VITE_CONVEX_SITE_URL, * routes: httpRoutes, * }); * * // GET endpoint * const opts = httpProxy.todos.get.queryOptions({ id: '123' }); * const query = createQuery(() => opts); * * // POST endpoint * const mutation = createMutation(() => 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/solid/convex-solid.tsx /** @jsxImportSource solid-js */ /** * Convex Provider for SolidJS * * Provides ConvexClient via context and auth integration. */ const ConvexContext = createContext(); /** Get the ConvexClient instance from context */ function useConvex() { const client = useContext(ConvexContext); if (!client) throw new Error("useConvex must be used within a ConvexProvider"); return client; } function ConvexProvider(props) { return createComponent(ConvexContext.Provider, { get value() { return props.client; }, get children() { return props.children; } }); } function ConvexProviderWithAuth(props) { const client = props.client; const [isConvexLoading, setIsConvexLoading] = createSignal(true); const [isConvexAuthenticated, setIsConvexAuthenticated] = createSignal(false); createEffect(() => { const auth = props.useAuth(); const loading = auth.isLoading; const authenticated = auth.isAuthenticated; if (loading) return; if (!authenticated) { client.clearAuth(); setIsConvexLoading(false); setIsConvexAuthenticated(false); return; } client.setAuth(auth.fetchAccessToken, (isAuth) => { setIsConvexLoading(false); setIsConvexAuthenticated(isAuth); }); }); onCleanup(() => { client.clearAuth(); }); return createComponent(ConvexContext.Provider, { get value() { return props.client; }, get children() { return createComponent(ConvexAuthBridge, { get isAuthenticated() { return isConvexAuthenticated(); }, get isLoading() { return isConvexLoading(); }, get children() { return props.children; } }); } }); } /** Hook returning auth state from Convex */ function useConvexAuth() { const bridge = useConvexAuthBridge(); if (!bridge) return { isLoading: false, isAuthenticated: false }; return bridge; } //#endregion //#region src/solid/use-query-options.ts /** * Query options factories for Convex functions (SolidJS). * Port of the React version — uses ConvexClient directly instead of convex/react hooks. * * Note: In @tanstack/solid-query, UseMutationOptions and UseQueryOptions are * Accessor wrappers. We use SolidMutationOptions and SolidQueryOptions (plain * object types) for parameters and return values. */ /** * 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 authSkip = useAuthSkip(funcRef, { enabled: isSkipped ? false : enabled, skipUnauth: options?.skipUnauth }); const baseOptions = convexQuery(funcRef, isSkipped ? {} : args); const { skipUnauth: _, subscribe, ...queryOptions } = options ?? {}; return { ...baseOptions, ...queryOptions, enabled: isSkipped ? false : !authSkip.shouldSkip, meta: { ...baseOptions.meta, authType: authSkip.authType, subscribe: subscribe !== false } }; } /** * 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 authSkip = useAuthSkip(funcRef, { enabled: isSkipped ? false : enabledOpt, skipUnauth: opts.skipUnauth }); const enabled = isSkipped || authSkip.shouldSkip ? false : enabledOpt; const baseOptions = convexInfiniteQueryOptions(funcRef, isSkipped ? {} : args, { ...opts, enabled }, meta); return { ...baseOptions, meta: { ...baseOptions.meta, authType: authSkip.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 authSkip = useAuthSkip(action, { enabled: isSkipped ? false : enabled, skipUnauth: options?.skipUnauth }); const baseOptions = convexAction(action, isSkipped ? {} : args); const { skipUnauth: _, ...queryOptions } = options ?? {}; return { ...baseOptions, ...queryOptions, enabled: isSkipped ? false : !authSkip.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 convex = useConvex(); const resolvedTransformer = getTransformer(transformer); return { ...options, mutationFn: async (args) => { if (authType === "required" && guard()) throw new CRPCClientError({ code: "UNAUTHORIZED", functionName: name }); return convex.mutation(mutation, 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 convex = useConvex(); const resolvedTransformer = getTransformer(transformer); return { ...options, mutationFn: async (args) => { if (authType === "required" && guard()) throw new CRPCClientError({ code: "UNAUTHORIZED", functionName: name }); return convex.action(action, 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 convex = useConvex(); return { ...options, mutationFn: async ({ file, ...args }) => { const result = await convex.mutation(generateUrlMutation, 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/solid/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/solid/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 === "onUpdate") return (args = {}, callback, onError) => { const funcRef = getFuncRef(api, path); return convexClient.onUpdate(funcRef, transformer.input.serialize(args), callback ? (result) => callback(transformer.output.deserialize(result)) : () => {}, onError); }; 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 ConvexClient instance * @returns A typed proxy with query/mutate methods * * @example * ```tsx * const client = createVanillaCRPCProxy(api, meta, convexClient); * * // Direct calls (no Solid 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/solid/context.tsx /** @jsxImportSource solid-js */ /** * CRPC Context and Provider * * Provides Solid context for the CRPC proxy, similar to tRPC's createTRPCContext. */ const ConvexQueryClientContext = createContext(null); /** Access ConvexQueryClient (e.g., for logout cleanup) */ const useConvexQueryClient = () => useContext(ConvexQueryClientContext); /** Headers record that allows empty objects and optional properties */ /** * Extract HTTP router from TApi['http'] if present (optional). * Uses NonNullable to handle optional http property. */ /** * 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/solid'; * * // Works for both regular Convex functions and generated HTTP router types * export const { useCRPC } = createCRPCContext({ * api, * convexSiteUrl: import.meta.env.VITE_CONVEX_SITE_URL, * }); * * // components/user-profile.tsx * function UserProfile({ id }) { * const crpc = useCRPC(); * const { data } = createQuery(() => crpc.user.get.queryOptions({ id })); * * // HTTP endpoints (if configured) * const { data: httpData } = createQuery(() => 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); function CRPCProvider(props) { const authStore = useAuthStore(); const fetchAccessToken = useFetchAccessToken(); const proxy = createCRPCOptionsProxy(api, meta, options.transformer); const vanillaClient = createVanillaCRPCProxy(api, meta, props.convexClient, options.transformer); const httpProxy = (() => { 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 }); })(); return createComponent(ConvexQueryClientContext.Provider, { get value() { return props.convexQueryClient; }, get children() { return createComponent(MetaContext.Provider, { value: meta, get children() { return createComponent(VanillaClientContext.Provider, { value: vanillaClient, get children() { return createComponent(HttpProxyContext.Provider, { value: httpProxy, get children() { return createComponent(CRPCProxyContext.Provider, { value: proxy, get children() { return props.children; } }); } }); } }); } }); } }); } 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; } 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.statusTe