Replace next.config.ts rewrites with catch-all API proxy route

The rewrites() approach resolves the backend URL once at server startup,
which always falls back to http://backend:8000 on Railway (where that
Docker Compose hostname doesn't exist). This caused ECONNREFUSED for
/api/analyze and all other proxied routes.

Fix: Add app/api/[...path]/route.ts that resolves the backend URL
per-request via getBackendUrl(), matching the pattern already used by
/api/chat and /api/auth/google/token routes.

Changes:
- New: frontend/app/api/[...path]/route.ts — catch-all proxy (GET/POST/PUT/PATCH/DELETE)
- Removed: frontend/app/api/chat/route.ts — now handled by catch-all
- Updated: frontend/next.config.ts — removed rewrites() block
- Updated: frontend/Dockerfile — cleared NEXT_PUBLIC_API_URL build default

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MarkLo127 2026-03-12 11:37:12 +08:00
parent ffc36edb97
commit b8454fefc7
4 changed files with 94 additions and 63 deletions

View File

@ -18,7 +18,9 @@ COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Set environment variable for build
ENV NEXT_PUBLIC_API_URL=http://backend:8000
# Leave NEXT_PUBLIC_API_URL empty at build time.
# Docker Compose sets BACKEND_URL at runtime; Railway users set it in env vars.
ENV NEXT_PUBLIC_API_URL=
# Build Next.js app
RUN bun run build

View File

@ -0,0 +1,86 @@
/**
* Catch-all API proxy route.
*
* Proxies every /api/* request that does NOT have a more-specific route handler
* (e.g. /api/chat, /api/auth/*, /api/config) to the FastAPI backend.
*
* Unlike next.config.ts rewrites(), this resolves the backend URL **per-request**
* so it works correctly on Railway and other platforms where the backend URL
* is only available at runtime via environment variables.
*/
import { NextRequest, NextResponse } from "next/server";
import { getBackendUrl } from "@/lib/backend-url";
const TIMEOUT_MS = 300_000; // 5 minutes analysis tasks can be long-running
async function proxyRequest(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path } = await params;
const backendUrl = getBackendUrl();
const target = `${backendUrl}/api/${path.join("/")}`;
// Preserve query string
const url = new URL(req.url);
const qs = url.search; // includes leading "?"
const fullTarget = `${target}${qs}`;
try {
// Forward relevant headers
const headers: Record<string, string> = {
"Content-Type": req.headers.get("content-type") || "application/json",
};
const auth = req.headers.get("authorization");
if (auth) {
headers["Authorization"] = auth;
}
// Read body for methods that have one
let body: string | undefined;
if (req.method !== "GET" && req.method !== "HEAD") {
body = await req.text();
}
const response = await fetch(fullTarget, {
method: req.method,
headers,
body,
signal: AbortSignal.timeout(TIMEOUT_MS),
});
const data = await response.text();
if (!response.ok) {
console.error(`[API Proxy] ${req.method} ${target}${response.status}`);
try {
return NextResponse.json(JSON.parse(data), { status: response.status });
} catch {
return NextResponse.json(
{ detail: `Backend error: ${response.status}` },
{ status: response.status },
);
}
}
// Try to return JSON; fall back to plain text
try {
return NextResponse.json(JSON.parse(data));
} catch {
return new NextResponse(data, {
status: 200,
headers: { "Content-Type": response.headers.get("content-type") || "text/plain" },
});
}
} catch (error: any) {
console.error(`[API Proxy] ${req.method} ${fullTarget} failed:`, error?.message || error);
return NextResponse.json(
{ detail: `Failed to connect to backend: ${error?.message || "Unknown error"}` },
{ status: 502 },
);
}
}
export const GET = proxyRequest;
export const POST = proxyRequest;
export const PUT = proxyRequest;
export const PATCH = proxyRequest;
export const DELETE = proxyRequest;

View File

@ -1,49 +0,0 @@
import { NextResponse } from "next/server";
import { getBackendUrl } from "@/lib/backend-url";
export async function POST(req: Request) {
try {
const backendUrl = getBackendUrl();
// Read the complete body from the request
const bodyText = await req.text();
console.log(`[API Route] Proxying /api/chat to ${backendUrl}/api/chat (${bodyText.length} bytes)`);
// Use native fetch to proxy the request to the backend.
// This bypasses the Next.js next.config.ts rewrites http-proxy,
// which has known bugs with large POST bodies and timeouts in standalone mode.
const response = await fetch(`${backendUrl}/api/chat`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: bodyText,
// @ts-ignore - Node.js fetch specific option to disable timeout
signal: AbortSignal.timeout ? AbortSignal.timeout(180_000) : undefined, // 3 minutes timeout
});
const data = await response.text();
if (!response.ok) {
console.error(`[API Route] Backend returned ${response.status}:`, data);
try {
const json = JSON.parse(data);
return NextResponse.json(json, { status: response.status });
} catch (e) {
return NextResponse.json(
{ detail: `Backend error: ${response.status}` },
{ status: response.status }
);
}
}
return NextResponse.json(JSON.parse(data));
} catch (error: any) {
console.error("[API Route] Proxy error:", error);
return NextResponse.json(
{ detail: `Failed to connect to backend: ${error.message}` },
{ status: 500 }
);
}
}

View File

@ -1,10 +1,9 @@
import type { NextConfig } from "next";
import { getBackendUrl } from "./lib/backend-url";
const nextConfig: NextConfig = {
output: 'standalone',
reactCompiler: true,
// Security headers
async headers() {
return [
@ -56,17 +55,10 @@ const nextConfig: NextConfig = {
];
},
async rewrites() {
const backendUrl = getBackendUrl();
console.log(`[Next.js] Rewriting API requests to: ${backendUrl}`);
return [
{
source: "/api/:path*",
destination: `${backendUrl}/api/:path*`,
},
];
},
// NOTE: API proxying is handled by the catch-all route handler at
// app/api/[...path]/route.ts which resolves the backend URL per-request.
// This is required for Railway where the backend URL is only available
// at runtime, not at server startup when rewrites() is evaluated.
};
export default nextConfig;