fix(frontend): proxy via API route no actions (#10296)

## Changes 🏗️

We noticed that in some pages ( `/build` _mainly_ ), where a lot of API
calls are fired in parallel using the old `BackendAPI`( _running many
agent executions_ ) the performance became worse. That is because the
`BackendAPI` was proxied via [server
actions](https://nextjs.org/docs/14/app/building-your-application/data-fetching/server-actions-and-mutations)
( _to make calls to our Backend on the Next.js server_ ).

Looks like server actions don't run in parallel, and their performance
is also subpar, given that we are not hosted on Vercel (they don't
utilise the edge middleware).

These changes cause all `BackendAPI` calls to be proxied via the Next.js
`/api/` route when executed on the browser; when executed on the server,
they bypass the proxy and directly access the API. Hopefully we gain:

- 🚀 Better Performance - API routes are faster than server actions for
this use case
- 🔧 Less Magic - Direct fetch calls instead of hidden server action
complexity
- ♻️ Code Reuse - Leveraging the existing proxy infrastructure used by
react-query
- 🎯 Cleaner Architecture - Single proxy pattern for all API calls
- 🔒 Same Security - Still uses server-side authentication with httpOnly
cookies

## Checklist 📋

### For code changes:
- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] E2E tests pass
  - [x] Click through the app, there is no issues
  - [x] Agent executions are fast again in the builder
  - [x] Test file uploads
This commit is contained in:
Ubbe
2025-07-03 19:18:47 +04:00
committed by GitHub
parent d4646c249d
commit 04e90da031
3 changed files with 154 additions and 32 deletions

View File

@@ -1,23 +1,5 @@
import { FC, useEffect, useMemo, useState } from "react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import SchemaTooltip from "@/components/SchemaTooltip";
import useCredentials from "@/hooks/useCredentials";
import { NotionLogoIcon } from "@radix-ui/react-icons";
import {
FaDiscord,
FaGithub,
FaTwitter,
FaGoogle,
FaMedium,
FaKey,
FaHubspot,
} from "react-icons/fa";
import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
CredentialsProviderName,
} from "@/lib/autogpt-server-api/types";
import { Button } from "@/components/ui/button";
import { IconKey, IconKeyPlus, IconUserPlus } from "@/components/ui/icons";
import {
Select,
@@ -27,12 +9,30 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import useCredentials from "@/hooks/useCredentials";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
CredentialsProviderName,
} from "@/lib/autogpt-server-api/types";
import { cn } from "@/lib/utils";
import { getHostFromUrl } from "@/lib/utils/url";
import { NotionLogoIcon } from "@radix-ui/react-icons";
import { FC, useEffect, useMemo, useState } from "react";
import {
FaDiscord,
FaGithub,
FaGoogle,
FaHubspot,
FaKey,
FaMedium,
FaTwitter,
} from "react-icons/fa";
import { APIKeyCredentialsModal } from "./api-key-credentials-modal";
import { UserPasswordCredentialsModal } from "./user-password-credentials-modal";
import { HostScopedCredentialsModal } from "./host-scoped-credentials-modal";
import { OAuth2FlowWaitingModal } from "./oauth2-flow-waiting-modal";
import { getHostFromUrl } from "@/lib/utils/url";
import { UserPasswordCredentialsModal } from "./user-password-credentials-modal";
const fallbackIcon = FaKey;

View File

@@ -2,7 +2,6 @@ import { getWebSocketToken } from "@/lib/supabase/actions";
import { getServerSupabase } from "@/lib/supabase/server/getServerSupabase";
import { createBrowserClient } from "@supabase/ssr";
import type { SupabaseClient } from "@supabase/supabase-js";
import { proxyApiRequest, proxyFileUpload } from "./proxy-action";
import type {
AddUserCreditsResponse,
AnalyticsDetails,
@@ -27,6 +26,7 @@ import type {
GraphID,
GraphMeta,
GraphUpdateable,
HostScopedCredentials,
LibraryAgent,
LibraryAgentID,
LibraryAgentPreset,
@@ -62,7 +62,6 @@ import type {
User,
UserOnboarding,
UserPasswordCredentials,
HostScopedCredentials,
UsersBalanceHistoryResponse,
} from "./types";
@@ -521,8 +520,6 @@ export default class BackendAPI {
}
uploadStoreSubmissionMedia(file: File): Promise<string> {
const formData = new FormData();
formData.append("file", file);
return this._uploadFile("/store/submissions/media", file);
}
@@ -813,8 +810,48 @@ export default class BackendAPI {
const formData = new FormData();
formData.append("file", file);
// Use proxy server action for secure file upload
return await proxyFileUpload(path, formData, this.baseUrl);
if (isClient) {
return this._makeClientFileUpload(path, formData);
} else {
return this._makeServerFileUpload(path, formData);
}
}
private async _makeClientFileUpload(
path: string,
formData: FormData,
): Promise<string> {
// Dynamic import is required even for client-only functions because helpers.ts
// has server-only imports (like getServerSupabase) at the top level. Static imports
// would bundle server-only code into the client bundle, causing runtime errors.
const { buildClientUrl, parseErrorResponse, handleFetchError } =
await import("./helpers");
const uploadUrl = buildClientUrl(path);
const response = await fetch(uploadUrl, {
method: "POST",
body: formData,
credentials: "include",
});
if (!response.ok) {
const errorData = await parseErrorResponse(response);
throw handleFetchError(response, errorData);
}
return await response.text();
}
private async _makeServerFileUpload(
path: string,
formData: FormData,
): Promise<string> {
const { makeAuthenticatedFileUpload, buildServerUrl } = await import(
"./helpers"
);
const url = buildServerUrl(path);
return await makeAuthenticatedFileUpload(url, formData);
}
private async _request(
@@ -826,13 +863,62 @@ export default class BackendAPI {
console.debug(`${method} ${path} payload:`, payload);
}
// Always use proxy server action to not expose any auth tokens to the browser
return await proxyApiRequest({
if (isClient) {
return this._makeClientRequest(method, path, payload);
} else {
return this._makeServerRequest(method, path, payload);
}
}
private async _makeClientRequest(
method: string,
path: string,
payload?: Record<string, any>,
) {
// Dynamic import is required even for client-only functions because helpers.ts
// has server-only imports (like getServerSupabase) at the top level. Static imports
// would bundle server-only code into the client bundle, causing runtime errors.
const {
buildClientUrl,
buildUrlWithQuery,
parseErrorResponse,
handleFetchError,
} = await import("./helpers");
const payloadAsQuery = ["GET", "DELETE"].includes(method);
let url = buildClientUrl(path);
if (payloadAsQuery && payload) {
url = buildUrlWithQuery(url, payload);
}
const response = await fetch(url, {
method,
path,
payload,
baseUrl: this.baseUrl,
headers: {
"Content-Type": "application/json",
},
body: !payloadAsQuery && payload ? JSON.stringify(payload) : undefined,
credentials: "include",
});
if (!response.ok) {
const errorData = await parseErrorResponse(response);
throw handleFetchError(response, errorData);
}
return await response.json();
}
private async _makeServerRequest(
method: string,
path: string,
payload?: Record<string, any>,
) {
const { makeAuthenticatedRequest, buildServerUrl } = await import(
"./helpers"
);
const url = buildServerUrl(path);
return await makeAuthenticatedRequest(method, url, payload);
}
////////////////////////////////////////

View File

@@ -29,6 +29,42 @@ export function buildRequestUrl(
return url;
}
export function buildClientUrl(path: string): string {
return `/api/proxy/api${path}`;
}
export function buildServerUrl(path: string): string {
const baseUrl =
process.env.NEXT_PUBLIC_AGPT_SERVER_URL || "http://localhost:8006/api";
return `${baseUrl}${path}`;
}
export function buildUrlWithQuery(
url: string,
payload?: Record<string, any>,
): string {
if (!payload) return url;
const queryParams = new URLSearchParams(payload);
return `${url}?${queryParams.toString()}`;
}
export function handleFetchError(response: Response, errorData: any): ApiError {
return new ApiError(
errorData?.error || "Request failed",
response.status,
errorData,
);
}
export async function parseErrorResponse(response: Response): Promise<any> {
try {
return await response.json();
} catch {
return { error: response.statusText };
}
}
export async function getServerAuthToken(): Promise<string> {
const supabase = await getServerSupabase();