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:
parent
ffc36edb97
commit
b8454fefc7
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue