diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 95e93301..6ca3f513 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -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 diff --git a/frontend/app/api/[...path]/route.ts b/frontend/app/api/[...path]/route.ts new file mode 100644 index 00000000..a40433e4 --- /dev/null +++ b/frontend/app/api/[...path]/route.ts @@ -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 = { + "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; diff --git a/frontend/app/api/chat/route.ts b/frontend/app/api/chat/route.ts deleted file mode 100644 index f55b469c..00000000 --- a/frontend/app/api/chat/route.ts +++ /dev/null @@ -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 } - ); - } -} diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 34a20338..1e55b45f 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -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;