From 95a5783da1fc994df76cb97238b55596dce4cd8f Mon Sep 17 00:00:00 2001 From: Tudor Date: Mon, 6 Jul 2026 10:00:42 +0100 Subject: [PATCH] fix(frontend): proxy /api and /sitemap.xml at runtime, not via baked rewrites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit next.config.js rewrites() bakes its destination into the build (routes-manifest.json), capturing FASTAPI_URL at build time. Because one frontend image is promoted staging->prod, the baked backend host forced every environment to name the backend service identically; staging names it 'backend_stg', so the browser's /api/* calls proxied to the baked 'http://backend' and failed with getaddrinfo ENOTFOUND backend. (SSR was unaffected because lib/api.ts reads FASTAPI_URL at runtime.) Replace the rewrites with route handlers that read FASTAPI_URL per request: - app/api/[...path]/route.ts — transparent proxy for all methods, streams the response, strips hop-by-hop headers, and returns 502 on upstream failure instead of crashing. - app/sitemap.xml/route.ts — proxies the backend sitemap (robots.ts points crawlers here). The same promoted image now adapts to whatever the backend is called in each environment. Verified: production build succeeds with /api/[...path] and /sitemap.xml as dynamic routes and an empty rewrites manifest. Co-Authored-By: Claude Fable 5 --- nextjs-app/Dockerfile | 4 +- nextjs-app/app/api/[...path]/route.ts | 75 +++++++++++++++++++++++++++ nextjs-app/app/sitemap.xml/route.ts | 32 ++++++++++++ nextjs-app/next.config.js | 19 ++----- 4 files changed, 114 insertions(+), 16 deletions(-) create mode 100644 nextjs-app/app/api/[...path]/route.ts create mode 100644 nextjs-app/app/sitemap.xml/route.ts diff --git a/nextjs-app/Dockerfile b/nextjs-app/Dockerfile index 42e0c6c..93d19e7 100644 --- a/nextjs-app/Dockerfile +++ b/nextjs-app/Dockerfile @@ -22,7 +22,9 @@ COPY . . ENV NEXT_TELEMETRY_DISABLED=1 ENV NODE_ENV=production -# Build argument for FastAPI URL (used by Next.js rewrites at build time) +# Default backend URL for any server-side fetch during `next build`. The +# runtime /api proxy reads FASTAPI_URL per request (see app/api/[...path]), +# so the deployed container's env is what actually routes traffic. ARG FASTAPI_URL=http://backend:80/api ENV FASTAPI_URL=${FASTAPI_URL} diff --git a/nextjs-app/app/api/[...path]/route.ts b/nextjs-app/app/api/[...path]/route.ts new file mode 100644 index 0000000..ba7cc85 --- /dev/null +++ b/nextjs-app/app/api/[...path]/route.ts @@ -0,0 +1,75 @@ +/** + * Runtime proxy for /api/* → the FastAPI backend. + * + * This replaces the old next.config.js `rewrites()` proxy, whose destination + * was baked into the build (routes-manifest.json) from FASTAPI_URL at build + * time. Because one frontend image is promoted staging→prod, a baked hostname + * forced every environment to name the backend identically; a mismatch (e.g. + * a `backend_stg` service) produced `getaddrinfo ENOTFOUND backend`. + * + * A route handler reads process.env.FASTAPI_URL on each request, so the same + * image adapts to whatever the backend is called in each environment. + */ + +import { type NextRequest, NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +// FASTAPI_URL already includes the `/api` suffix (e.g. http://backend:80/api). +function backendBase(): string { + return process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api'; +} + +// Hop-by-hop / length headers must not be copied across a proxy — undici has +// already decoded the body, so a stale content-encoding/length corrupts it. +const STRIPPED_RESPONSE_HEADERS = ['content-encoding', 'content-length', 'transfer-encoding', 'connection']; +const METHODS_WITH_BODY = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); + +async function handler(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) { + const { path } = await ctx.params; + const target = `${backendBase()}/${path.join('/')}${req.nextUrl.search}`; + + const headers = new Headers(req.headers); + headers.delete('host'); + headers.delete('connection'); + + const init: RequestInit & { duplex?: 'half' } = { + method: req.method, + headers, + redirect: 'manual', + cache: 'no-store', + }; + if (METHODS_WITH_BODY.has(req.method)) { + init.body = req.body; + init.duplex = 'half'; + } + + let upstream: Response; + try { + upstream = await fetch(target, init); + } catch (err) { + // e.g. DNS failure or connection refused — surface a clean 502 instead of + // an opaque proxy crash so callers can degrade gracefully. + return NextResponse.json({ detail: 'Upstream request failed' }, { status: 502 }); + } + + const responseHeaders = new Headers(upstream.headers); + for (const h of STRIPPED_RESPONSE_HEADERS) responseHeaders.delete(h); + + return new NextResponse(upstream.body, { + status: upstream.status, + statusText: upstream.statusText, + headers: responseHeaders, + }); +} + +export { + handler as GET, + handler as HEAD, + handler as POST, + handler as PUT, + handler as PATCH, + handler as DELETE, + handler as OPTIONS, +}; diff --git a/nextjs-app/app/sitemap.xml/route.ts b/nextjs-app/app/sitemap.xml/route.ts new file mode 100644 index 0000000..acb504d --- /dev/null +++ b/nextjs-app/app/sitemap.xml/route.ts @@ -0,0 +1,32 @@ +/** + * Runtime proxy for /sitemap.xml → the FastAPI backend's generated sitemap. + * + * Like the /api/* proxy, this reads FASTAPI_URL at request time rather than + * baking the backend host into the build, so one image works in every + * environment. robots.ts points crawlers here. + */ + +import { NextResponse } from 'next/server'; + +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +function backendOrigin(): string { + const base = process.env.FASTAPI_URL || process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api'; + return base.replace(/\/api$/, ''); +} + +export async function GET() { + let upstream: Response; + try { + upstream = await fetch(`${backendOrigin()}/sitemap.xml`, { cache: 'no-store' }); + } catch { + return new NextResponse('Sitemap temporarily unavailable', { status: 502 }); + } + + const body = await upstream.text(); + return new NextResponse(body, { + status: upstream.status, + headers: { 'content-type': upstream.headers.get('content-type') || 'application/xml' }, + }); +} diff --git a/nextjs-app/next.config.js b/nextjs-app/next.config.js index 8e0ce52..843b4f0 100644 --- a/nextjs-app/next.config.js +++ b/nextjs-app/next.config.js @@ -3,21 +3,10 @@ const nextConfig = { // Enable standalone output for Docker output: 'standalone', - // API Proxy to FastAPI backend - async rewrites() { - const apiUrl = process.env.FASTAPI_URL || 'http://localhost:8000/api'; - const backendUrl = apiUrl.replace(/\/api$/, ''); - return [ - { - source: '/api/:path*', - destination: `${apiUrl}/:path*`, - }, - { - source: '/sitemap.xml', - destination: `${backendUrl}/sitemap.xml`, - }, - ]; - }, + // The /api/* and /sitemap.xml proxies to the FastAPI backend are route + // handlers (app/api/[...path]/route.ts, app/sitemap.xml/route.ts) rather + // than rewrites, so the backend host is read from FASTAPI_URL at runtime + // instead of being baked into the build. // Image optimization images: {