diff --git a/package-lock.json b/package-lock.json
index ba12d31aa..8ca6feba6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8511,6 +8511,10 @@
"resolved": "apps/provider-proxy",
"link": true
},
+ "node_modules/@akashnetwork/react-query-proxy": {
+ "resolved": "packages/react-query-proxy",
+ "link": true
+ },
"node_modules/@akashnetwork/react-query-sdk": {
"resolved": "packages/react-query-sdk",
"link": true
@@ -57932,6 +57936,21 @@
"jotai": "^2.9.2"
}
},
+ "packages/react-query-proxy": {
+ "name": "@akashnetwork/react-query-proxy",
+ "version": "1.0.0",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@tanstack/react-query": "^5.67.2"
+ },
+ "devDependencies": {
+ "@akashnetwork/dev-config": "*",
+ "jest": "^29.7.0",
+ "prettier": "^3.3.0",
+ "ts-jest": "^29.4.0",
+ "typescript": "~5.8.2"
+ }
+ },
"packages/react-query-sdk": {
"name": "@akashnetwork/react-query-sdk",
"version": "1.0.0",
diff --git a/packages/react-query-proxy/README.md b/packages/react-query-proxy/README.md
new file mode 100644
index 000000000..8e278c948
--- /dev/null
+++ b/packages/react-query-proxy/README.md
@@ -0,0 +1,106 @@
+# React Query Proxy
+
+> Wrap any async service into fully-typed TanStack React Query hooks at runtime.
+> Works with REST, gRPC, XML-RPC, generated clients, or hand-written APIs β no codegen required.
+
+---
+
+## β¨ Why?
+
+Most teams already have an API client / SDK:
+- generated from OpenAPI / gRPC
+- written by hand
+- shared between frontend, backend, scripts, and tests
+
+This library lets you **reuse that SDK directly in React** by turning it into:
+- `useQuery` hooks
+- `useMutation` hooks
+- stable, predictable query keys
+
+All **at runtime**, without generating code or schemas.
+
+---
+
+## π¦ Installation
+
+```bash
+npm install @akashnetwork/react-query-proxy
+```
+
+> Peer dependencies:
+> - `react >= 18`
+> - `@tanstack/react-query >= 5`
+
+---
+
+## π Basic usage
+
+### 1. Start with a plain async SDK
+
+```ts
+const sdk = {
+ alerts: {
+ async list(input?: { page?: number }) {
+ return fetchAlerts(input)
+ },
+
+ async create(input: { name: string }) {
+ return createAlert(input)
+ },
+ },
+}
+```
+
+---
+
+### 2. Wrap it with `createProxy`
+
+```ts
+import { createProxy } from '@akashnetwork/react-query-proxy'
+
+export const api = createProxy(sdk)
+```
+
+---
+
+### 3. Use queries
+
+```tsx
+function AlertsList() {
+ const q = api.alerts.list.useQuery({ page: 1 })
+
+ if (q.isLoading) return
Loadingβ¦
+ if (q.isError) return Error
+
+ return {JSON.stringify(q.data, null, 2)}
+}
+```
+
+---
+
+### 4. Use mutations
+
+```tsx
+function CreateAlert() {
+ const m = api.alerts.create.useMutation()
+
+ return (
+
+ )
+}
+```
+
+---
+
+## π Query keys & invalidation
+
+```ts
+api.alerts.list.getKey({ page: 1 })
+// β ['alerts', 'list', { page: 1 }]
+```
+
+## π License
+
+Apache 2.0
diff --git a/packages/react-query-proxy/package.json b/packages/react-query-proxy/package.json
new file mode 100644
index 000000000..6b3fcac8b
--- /dev/null
+++ b/packages/react-query-proxy/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@akashnetwork/react-query-proxy",
+ "version": "1.0.0",
+ "description": "",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/akash-network/console"
+ },
+ "license": "Apache-2.0",
+ "author": "Akash Network",
+ "main": "src/index.ts",
+ "scripts": {
+ "test": "jest",
+ "validate:types": "tsc --noEmit && echo"
+ },
+ "dependencies": {
+ "@tanstack/react-query": "^5.67.2"
+ },
+ "devDependencies": {
+ "@akashnetwork/dev-config": "*",
+ "jest": "^29.7.0",
+ "prettier": "^3.3.0",
+ "ts-jest": "^29.4.0",
+ "typescript": "~5.8.2"
+ }
+}
diff --git a/packages/react-query-proxy/src/createProxy/createProxy.spec.ts b/packages/react-query-proxy/src/createProxy/createProxy.spec.ts
new file mode 100644
index 000000000..7eb8644ec
--- /dev/null
+++ b/packages/react-query-proxy/src/createProxy/createProxy.spec.ts
@@ -0,0 +1,284 @@
+import { useMutation, useQuery } from "@tanstack/react-query";
+
+import { createProxy } from "./createProxy";
+
+jest.mock("@tanstack/react-query", () => ({
+ useQuery: jest.fn(),
+ useMutation: jest.fn()
+}));
+
+const useQueryMock = useQuery as jest.Mock;
+const useMutationMock = useMutation as jest.Mock;
+
+interface User {
+ id: number;
+ name?: string;
+}
+
+interface Sdk {
+ users: {
+ list: (input?: { page?: number }) => Promise;
+ getById: (input: { id: number }) => Promise;
+ create: (input: { name: string }) => Promise;
+ };
+ admin: {
+ settings: {
+ update: (input: { theme: string }) => Promise<{ success: boolean }>;
+ };
+ };
+}
+
+describe(createProxy.name, () => {
+ beforeEach(() => {
+ jest.clearAllMocks();
+ useQueryMock.mockReturnValue({ data: undefined, isLoading: true });
+ useMutationMock.mockReturnValue({ mutate: jest.fn(), isPending: false });
+ });
+
+ describe("getKey", () => {
+ it("returns path segments with input when input is provided", () => {
+ const { proxy } = setup();
+ const key = proxy.users.getById.getKey({ id: 123 });
+ expect(key).toEqual(["users", { id: 123 }]);
+ });
+
+ it("returns path segments without input when input is undefined", () => {
+ const { proxy } = setup();
+ const key = proxy.users.list.getKey(undefined);
+ expect(key).toEqual(["users"]);
+ });
+
+ it("returns path segments without input when input is null", () => {
+ const { proxy } = setup();
+ const key = proxy.users.list.getKey(null as unknown as undefined);
+ expect(key).toEqual(["users"]);
+ });
+ });
+
+ describe("useQuery", () => {
+ it("calls useQuery with correct queryKey and queryFn", () => {
+ const { proxy, sdk } = setup();
+ proxy.users.getById.useQuery({ id: 42 });
+
+ expect(useQueryMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ queryKey: ["users", { id: 42 }]
+ })
+ );
+
+ const queryFn = useQueryMock.mock.calls[0][0].queryFn;
+ queryFn();
+ expect(sdk.users.getById).toHaveBeenCalledWith({ id: 42 });
+ });
+
+ it("appends custom queryKey to the generated key", () => {
+ const { proxy } = setup();
+ proxy.users.getById.useQuery({ id: 1 }, { queryKey: ["extra", "key"] });
+
+ expect(useQueryMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ queryKey: ["users", { id: 1 }, "extra", "key"]
+ })
+ );
+ });
+
+ it("passes through additional options to useQuery", () => {
+ const { proxy } = setup();
+ proxy.users.getById.useQuery({ id: 1 }, { enabled: false, staleTime: 5000 });
+
+ expect(useQueryMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ enabled: false,
+ staleTime: 5000
+ })
+ );
+ });
+
+ it("handles undefined input for optional parameters", () => {
+ const { proxy, sdk } = setup();
+ proxy.users.list.useQuery(undefined);
+
+ expect(useQueryMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ queryKey: ["users"]
+ })
+ );
+
+ const queryFn = useQueryMock.mock.calls[0][0].queryFn;
+ queryFn();
+ expect(sdk.users.list).toHaveBeenCalledWith(undefined);
+ });
+ });
+
+ describe("useMutation", () => {
+ it("calls useMutation with correct mutationKey and mutationFn", () => {
+ const { proxy, sdk } = setup();
+ proxy.users.create.useMutation();
+
+ expect(useMutationMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ mutationKey: ["users"]
+ })
+ );
+
+ const mutationFn = useMutationMock.mock.calls[0][0].mutationFn;
+ mutationFn({ name: "John" });
+ expect(sdk.users.create).toHaveBeenCalledWith({ name: "John" });
+ });
+
+ it("appends custom mutationKey to the generated key", () => {
+ const { proxy } = setup();
+ proxy.users.create.useMutation({ mutationKey: ["custom"] });
+
+ expect(useMutationMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ mutationKey: ["users", "custom"]
+ })
+ );
+ });
+
+ it("passes through additional options to useMutation", () => {
+ const onSuccess = jest.fn();
+ const { proxy } = setup();
+ proxy.users.create.useMutation({ onSuccess });
+
+ expect(useMutationMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ onSuccess
+ })
+ );
+ });
+ });
+
+ describe("nested objects", () => {
+ it("creates proxy for deeply nested methods", () => {
+ const { proxy, sdk } = setup();
+ proxy.admin.settings.update.useQuery({ theme: "dark" });
+
+ expect(useQueryMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ queryKey: ["admin", "settings", { theme: "dark" }]
+ })
+ );
+
+ const queryFn = useQueryMock.mock.calls[0][0].queryFn;
+ queryFn();
+ expect(sdk.admin.settings.update).toHaveBeenCalledWith({ theme: "dark" });
+ });
+
+ it("generates correct getKey for nested methods", () => {
+ const { proxy } = setup();
+ const key = proxy.admin.settings.update.getKey({ theme: "light" });
+ expect(key).toEqual(["admin", "settings", { theme: "light" }]);
+ });
+ });
+
+ describe("caching", () => {
+ it("returns the same proxy instance for the same object", () => {
+ const sdk = createSdk();
+ const proxy1 = createProxy(sdk);
+ const proxy2 = createProxy(sdk);
+
+ expect(proxy1).toBe(proxy2);
+ });
+
+ it("returns the same nested proxy for repeated access", () => {
+ const { proxy } = setup();
+ const users1 = proxy.users;
+ const users2 = proxy.users;
+
+ expect(users1).toBe(users2);
+ });
+
+ it("returns the same hooks object for repeated method access", () => {
+ const { proxy } = setup();
+ const getById1 = proxy.users.getById;
+ const getById2 = proxy.users.getById;
+
+ expect(getById1).toBe(getById2);
+ });
+ });
+
+ describe("inputToKey option", () => {
+ it("uses custom inputToKey function for query keys", () => {
+ const sdk = createSdk();
+ const proxy = createProxy(sdk, {
+ inputToKey: (input: unknown) => {
+ if (input && typeof input === "object" && "id" in input) {
+ return [(input as { id: number }).id];
+ }
+ return [];
+ }
+ });
+
+ const key = proxy.users.getById.getKey({ id: 99 });
+ expect(key).toEqual(["users", 99]);
+ });
+
+ it("applies custom inputToKey in useQuery", () => {
+ const sdk = createSdk();
+ const proxy = createProxy(sdk, {
+ inputToKey: (input: unknown) => (input ? [JSON.stringify(input)] : [])
+ });
+
+ proxy.users.getById.useQuery({ id: 5 });
+
+ expect(useQueryMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ queryKey: ["users", '{"id":5}']
+ })
+ );
+ });
+ });
+
+ describe("edge cases", () => {
+ it("returns undefined for non-existent properties", () => {
+ const { proxy } = setup();
+ expect((proxy as Record).nonExistent).toBeUndefined();
+ });
+
+ it("returns primitive values as-is", () => {
+ const sdk = {
+ version: "1.0.0",
+ count: 42
+ };
+ const proxy = createProxy(sdk);
+
+ expect(proxy.version).toBe("1.0.0");
+ expect(proxy.count).toBe(42);
+ });
+
+ it("handles null values in object", () => {
+ const sdk = {
+ nullValue: null as null,
+ users: {
+ list: jest.fn()
+ }
+ };
+ const proxy = createProxy(sdk);
+
+ expect(proxy.nullValue).toBeNull();
+ });
+ });
+
+ function setup() {
+ const sdk = createSdk();
+ const proxy = createProxy(sdk);
+ return { sdk, proxy };
+ }
+
+ function createSdk(): Sdk {
+ return {
+ users: {
+ list: jest.fn().mockResolvedValue([]),
+ getById: jest.fn().mockResolvedValue({ id: 1 }),
+ create: jest.fn().mockResolvedValue({ id: 1 })
+ },
+ admin: {
+ settings: {
+ update: jest.fn().mockResolvedValue({ success: true })
+ }
+ }
+ };
+ }
+});
diff --git a/packages/react-query-proxy/src/createProxy/createProxy.ts b/packages/react-query-proxy/src/createProxy/createProxy.ts
new file mode 100644
index 000000000..0a48cefe5
--- /dev/null
+++ b/packages/react-query-proxy/src/createProxy/createProxy.ts
@@ -0,0 +1,96 @@
+import type { QueryKey, UseMutationOptions, UseMutationResult, UseQueryOptions, UseQueryResult } from "@tanstack/react-query";
+import { useMutation, useQuery } from "@tanstack/react-query";
+
+export function createProxy>(object: T, proxyOptions?: CreateProxyOptions): RecursiveHooksProxy {
+ return createRecursiveProxyImpl(object, proxyOptions);
+}
+
+export interface CreateProxyOptions {
+ inputToKey?: (input: unknown) => PropertyKey[];
+}
+
+const proxyCache = new WeakMap