2026-07-06 10:00:42 +01:00
|
|
|
/**
|
|
|
|
|
* 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']);
|
|
|
|
|
|
2026-08-23 10:54:26 +01:00
|
|
|
/*
|
|
|
|
|
* API paths this public proxy must not forward.
|
|
|
|
|
*
|
|
|
|
|
* Matched on the first segment, exactly — a prefix match would take
|
|
|
|
|
* /api/flagship down with /api/flags.
|
|
|
|
|
*
|
|
|
|
|
* `flags` is here because GET /api/flags names every unreleased feature the
|
|
|
|
|
* codebase knows about, along with whether it is on. Publishing that defeats
|
|
|
|
|
* the point of shipping dark. Next reads it server-side via FASTAPI_URL, on
|
|
|
|
|
* the Docker network, which never transits this route.
|
|
|
|
|
*
|
|
|
|
|
* Anything else internal-only belongs here too.
|
|
|
|
|
*/
|
|
|
|
|
const INTERNAL_ONLY_SEGMENTS = new Set(['flags']);
|
|
|
|
|
|
2026-07-06 10:00:42 +01:00
|
|
|
async function handler(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
|
|
|
|
const { path } = await ctx.params;
|
2026-08-23 10:54:26 +01:00
|
|
|
if (INTERNAL_ONLY_SEGMENTS.has(path[0])) {
|
|
|
|
|
return NextResponse.json({ detail: 'Not Found' }, { status: 404 });
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 10:00:42 +01:00
|
|
|
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,
|
|
|
|
|
};
|