refactor(web): replace BACKEND_URL with buildBackendUrl for dynamic URL construction

This commit is contained in:
Anish Sarkar 2026-06-16 04:56:23 +05:30
parent 66659ee8d3
commit 371ff866c7
9 changed files with 138 additions and 79 deletions

View file

@ -106,7 +106,7 @@ import {
extractUserTurnForNewChatApi,
type NewChatUserImagePayload,
} from "@/lib/chat/user-turn-api-parts";
import { BACKEND_URL } from "@/lib/env-config";
import { buildBackendUrl } from "@/lib/env-config";
import { NotFoundError } from "@/lib/error";
import {
trackChatBlocked,
@ -919,10 +919,9 @@ export default function NewChatPage() {
if (threadId) {
const token = getBearerToken();
if (token) {
const backendUrl = BACKEND_URL;
try {
const response = await fetch(
`${backendUrl}/api/v1/threads/${threadId}/cancel-active-turn`,
buildBackendUrl(`/api/v1/threads/${threadId}/cancel-active-turn`),
{
method: "POST",
headers: {
@ -1110,7 +1109,6 @@ export default function NewChatPage() {
let streamBatcher: FrameBatchedUpdater | null = null;
try {
const backendUrl = BACKEND_URL;
const selection = await getAgentFilesystemSelection(searchSpaceId, {
localFilesystemEnabled,
});
@ -1147,7 +1145,7 @@ export default function NewChatPage() {
}
const response = await fetchWithTurnCancellingRetry(() =>
fetch(`${backendUrl}/api/v1/new_chat`, {
fetch(buildBackendUrl("/api/v1/new_chat"), {
method: "POST",
headers: {
"Content-Type": "application/json",
@ -1642,12 +1640,11 @@ export default function NewChatPage() {
}
try {
const backendUrl = BACKEND_URL;
const selection = await getAgentFilesystemSelection(searchSpaceId, {
localFilesystemEnabled,
});
const response = await fetchWithTurnCancellingRetry(() =>
fetch(`${backendUrl}/api/v1/threads/${resumeThreadId}/resume`, {
fetch(buildBackendUrl(`/api/v1/threads/${resumeThreadId}/resume`), {
method: "POST",
headers: {
"Content-Type": "application/json",

View file

@ -13,7 +13,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { authenticatedFetch } from "@/lib/auth-utils";
import { getReauthEndpoint } from "@/lib/connector-telemetry";
import { BACKEND_URL } from "@/lib/env-config";
import { buildBackendUrl } from "@/lib/env-config";
import { cn } from "@/lib/utils";
import { DateRangeSelector } from "../../components/date-range-selector";
import { PeriodicSyncConfig } from "../../components/periodic-sync-config";
@ -95,12 +95,13 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
if (!spaceId || !reauthEndpoint) return;
setReauthing(true);
try {
const backendUrl = BACKEND_URL;
const url = new URL(`${backendUrl}${reauthEndpoint}`);
url.searchParams.set("connector_id", String(connector.id));
url.searchParams.set("space_id", String(spaceId));
url.searchParams.set("return_url", window.location.pathname);
const response = await authenticatedFetch(url.toString());
const response = await authenticatedFetch(
buildBackendUrl(reauthEndpoint, {
connector_id: connector.id,
space_id: spaceId,
return_url: window.location.pathname,
})
);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
toast.error(data.detail ?? "Failed to initiate re-authentication.");

View file

@ -12,7 +12,7 @@ import { getConnectorIcon } from "@/contracts/enums/connectorIcons";
import type { SearchSourceConnector } from "@/contracts/types/connector.types";
import { authenticatedFetch } from "@/lib/auth-utils";
import { getReauthEndpoint } from "@/lib/connector-telemetry";
import { BACKEND_URL } from "@/lib/env-config";
import { buildBackendUrl } from "@/lib/env-config";
import { formatRelativeDate } from "@/lib/format-date";
import { cn } from "@/lib/utils";
import { LIVE_CONNECTOR_TYPES } from "../constants/connector-constants";
@ -61,12 +61,13 @@ export const ConnectorAccountsListView: FC<ConnectorAccountsListViewProps> = ({
if (!searchSpaceId || !endpoint) return;
setReauthingId(connector.id);
try {
const backendUrl = BACKEND_URL;
const url = new URL(`${backendUrl}${endpoint}`);
url.searchParams.set("connector_id", String(connector.id));
url.searchParams.set("space_id", String(searchSpaceId));
url.searchParams.set("return_url", window.location.pathname);
const response = await authenticatedFetch(url.toString());
const response = await authenticatedFetch(
buildBackendUrl(endpoint, {
connector_id: connector.id,
space_id: searchSpaceId,
return_url: window.location.pathname,
})
);
if (!response.ok) {
const data = await response.json().catch(() => ({}));
toast.error(data.detail ?? "Failed to initiate re-authentication.");

View file

@ -34,7 +34,7 @@ import { useMediaQuery } from "@/hooks/use-media-query";
import { useElectronAPI } from "@/hooks/use-platform";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
import { inferMonacoLanguageFromPath } from "@/lib/editor-language";
import { BACKEND_URL } from "@/lib/env-config";
import { buildBackendUrl } from "@/lib/env-config";
const PlateEditor = dynamic(
() => import("@/components/editor/plate-editor").then((m) => ({ default: m.PlateEditor })),
@ -260,10 +260,12 @@ export function EditorPanelContent({
return;
}
const url = new URL(
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
const response = await authenticatedFetch(
buildBackendUrl(
`/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
),
{ method: "GET" }
);
const response = await authenticatedFetch(url.toString(), { method: "GET" });
if (controller.signal.aborted) return;
@ -402,7 +404,7 @@ export function EditorPanelContent({
return;
}
const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`,
buildBackendUrl(`/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
@ -496,7 +498,9 @@ export function EditorPanelContent({
setDownloading(true);
try {
const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`,
buildBackendUrl(
`/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`
),
{ method: "GET" }
);
if (!response.ok) throw new Error("Download failed");

View file

@ -11,7 +11,7 @@ import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { authenticatedFetch, getBearerToken, redirectToLogin } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config";
import { buildBackendUrl } from "@/lib/env-config";
const LARGE_DOCUMENT_THRESHOLD = 2 * 1024 * 1024; // 2MB
@ -108,10 +108,12 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
}
try {
const url = new URL(
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
const response = await authenticatedFetch(
buildBackendUrl(
`/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/editor-content`
),
{ method: "GET" }
);
const response = await authenticatedFetch(url.toString(), { method: "GET" });
if (controller.signal.aborted) return;
@ -165,7 +167,7 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
setSaving(true);
try {
const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`,
buildBackendUrl(`/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/save`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
@ -323,7 +325,9 @@ export function DocumentTabContent({ documentId, searchSpaceId, title }: Documen
setDownloading(true);
try {
const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`,
buildBackendUrl(
`/api/v1/search-spaces/${searchSpaceId}/documents/${documentId}/download-markdown`
),
{ method: "GET" }
);
if (!response.ok) throw new Error("Download failed");

View file

@ -9,6 +9,32 @@ const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [...compat.extends("next/core-web-vitals", "next/typescript")];
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
rules: {
"no-restricted-syntax": [
"error",
{
selector:
"NewExpression[callee.name='URL'] TemplateLiteral Identifier[name='BACKEND_URL']",
message:
"Use buildBackendUrl(path, params) for backend URLs. BACKEND_URL may be empty in proxy mode, and new URL('/relative') throws without a base.",
},
{
selector:
"NewExpression[callee.name='URL'] TemplateLiteral Identifier[name='backendUrl']",
message:
"Use buildBackendUrl(path, params) for backend URLs instead of aliasing BACKEND_URL into new URL().",
},
{
selector: "VariableDeclarator[id.name='backendUrl'][init.name='BACKEND_URL']",
message:
"Do not alias BACKEND_URL for URL construction. Use buildBackendUrl(path, params) instead.",
},
],
},
},
];
export default eslintConfig;

View file

@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { authenticatedFetch } from "@/lib/auth-utils";
import { BACKEND_URL } from "@/lib/env-config";
import { buildBackendUrl } from "@/lib/env-config";
export interface SearchSourceConnector {
id: number;
name: string;
@ -106,16 +106,15 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
setIsLoading(true);
setError(null);
// Build URL with optional search_space_id query parameter
const url = new URL(`${BACKEND_URL}/api/v1/search-source-connectors`);
if (spaceId !== undefined) {
url.searchParams.append("search_space_id", spaceId.toString());
}
const response = await authenticatedFetch(url.toString(), {
method: "GET",
headers: { "Content-Type": "application/json" },
});
const response = await authenticatedFetch(
buildBackendUrl("/api/v1/search-source-connectors", {
search_space_id: spaceId,
}),
{
method: "GET",
headers: { "Content-Type": "application/json" },
}
);
if (!response.ok) {
throw new Error(`Failed to fetch connectors: ${response.statusText}`);
@ -166,15 +165,16 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
spaceId: number
) => {
try {
// Add search_space_id as a query parameter
const url = new URL(`${BACKEND_URL}/api/v1/search-source-connectors`);
url.searchParams.append("search_space_id", spaceId.toString());
const response = await authenticatedFetch(url.toString(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(connectorData),
});
const response = await authenticatedFetch(
buildBackendUrl("/api/v1/search-source-connectors", {
search_space_id: spaceId,
}),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(connectorData),
}
);
if (!response.ok) {
throw new Error(`Failed to create connector: ${response.statusText}`);
@ -204,7 +204,7 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
) => {
try {
const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`,
buildBackendUrl(`/api/v1/search-source-connectors/${connectorId}`),
{
method: "PUT",
headers: { "Content-Type": "application/json" },
@ -235,7 +235,7 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
const deleteConnector = async (connectorId: number) => {
try {
const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/search-source-connectors/${connectorId}`,
buildBackendUrl(`/api/v1/search-source-connectors/${connectorId}`),
{
method: "DELETE",
headers: { "Content-Type": "application/json" },
@ -267,19 +267,12 @@ export const useSearchSourceConnectors = (lazy: boolean = false, searchSpaceId?:
endDate?: string
) => {
try {
// Build query parameters
const params = new URLSearchParams({
search_space_id: searchSpaceId.toString(),
});
if (startDate) {
params.append("start_date", startDate);
}
if (endDate) {
params.append("end_date", endDate);
}
const response = await authenticatedFetch(
`${BACKEND_URL}/api/v1/search-source-connectors/${connectorId}/index?${params.toString()}`,
buildBackendUrl(`/api/v1/search-source-connectors/${connectorId}/index`, {
search_space_id: searchSpaceId,
start_date: startDate,
end_date: endDate,
}),
{
method: "POST",
headers: { "Content-Type": "application/json" },

View file

@ -1,5 +1,5 @@
import type { ZodType } from "zod";
import { BACKEND_URL } from "@/lib/env-config";
import { buildBackendUrl } from "@/lib/env-config";
import { getClientPlatform } from "../agent-filesystem";
import { getBearerToken, handleUnauthorized, refreshAccessToken } from "../auth-utils";
import {
@ -31,8 +31,6 @@ export type RequestOptions = {
};
class BaseApiService {
baseUrl: string;
noAuthEndpoints: string[] = ["/auth/jwt/login", "/auth/register", "/auth/refresh"];
// Prefixes that don't require auth (checked with startsWith)
@ -44,12 +42,9 @@ class BaseApiService {
return typeof window !== "undefined" ? getBearerToken() || "" : "";
}
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
// Keep for backward compatibility, but token is now always read from localStorage
setBearerToken(_bearerToken: string) {
void _bearerToken;
// No-op: token is now always read fresh from localStorage via the getter
}
@ -102,8 +97,7 @@ class BaseApiService {
throw new AuthenticationError("You are not authenticated. Please login again.");
}
// Construct the full URL. Empty baseUrl is valid for same-origin proxy mode.
const fullUrl = this.baseUrl ? new URL(url, this.baseUrl).toString() : url;
const fullUrl = buildBackendUrl(url);
// Prepare fetch options
const fetchOptions: RequestInit = {
@ -379,7 +373,8 @@ class BaseApiService {
options?: Omit<RequestOptions, "method" | "responseType" | "body"> & { body: FormData }
) {
// Remove Content-Type from options headers if present
const { "Content-Type": _, ...headersWithoutContentType } = options?.headers ?? {};
const headersWithoutContentType = { ...(options?.headers ?? {}) };
delete headersWithoutContentType["Content-Type"];
return this.request(url, responseSchema, {
method: "POST",
@ -394,4 +389,4 @@ class BaseApiService {
}
}
export const baseApiService = new BaseApiService(BACKEND_URL);
export const baseApiService = new BaseApiService();

View file

@ -16,6 +16,44 @@ export const AUTH_TYPE = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE || "G
// same-origin relative requests (e.g. /api/v1/... and /auth/...).
export const BACKEND_URL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL ?? "";
type BackendUrlParam = string | number | boolean | null | undefined;
/**
* Build browser-facing backend URLs without breaking proxy mode.
*
* In proxy mode BACKEND_URL intentionally stays empty, so callers must keep
* same-origin relative URLs ("/api/v1/...") and let Caddy route them. When
* BACKEND_URL is explicitly configured, the same path resolves against that
* absolute backend origin.
*/
export function buildBackendUrl(
path: string,
params?: Record<string, BackendUrlParam>
): string {
const backendPath = path.startsWith("/") ? path : `/${path}`;
const queryParams = new URLSearchParams();
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== null && value !== undefined) {
queryParams.append(key, String(value));
}
}
}
if (BACKEND_URL) {
const url = new URL(backendPath, BACKEND_URL);
for (const [key, value] of queryParams) {
url.searchParams.append(key, value);
}
return url.toString();
}
const queryString = queryParams.toString();
if (!queryString) return backendPath;
return `${backendPath}${backendPath.includes("?") ? "&" : "?"}${queryString}`;
}
// Server-side backend URL. Relative browser URLs do not work from RSC/API route
// code, so server callers should use Docker DNS or an explicit public backend.
export const SERVER_BACKEND_URL =