Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 65 additions & 21 deletions frontend/src/app/api/[...path]/route.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,85 @@
import { NextRequest, NextResponse } from "next/server";

const API_URL = process.env.API_URL;
// Forwards /api/* to the FastAPI backend when nothing else is in front of the frontend
// (e.g. on Vercel). Under docker compose, nginx serves /api itself and this never runs.

async function proxy(request: NextRequest, path: string) {
export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const API_URL = process.env.API_URL?.replace(/\/+$/, "");

// Only what the API reads. Cookies and the host header stay on this side.
const FORWARDED_REQUEST_HEADERS = [
"authorization",
"content-type",
"accept",
"accept-language",
"last-event-id",
];

// fetch() hands back a decoded body, so the upstream framing headers no longer describe it.
const DROPPED_RESPONSE_HEADERS = [
"content-encoding",
"content-length",
"transfer-encoding",
"connection",
"keep-alive",
];

async function proxy(request: NextRequest) {
if (!API_URL) {
return new NextResponse("API_URL not configured", { status: 503 });
return NextResponse.json({ detail: "API_URL not configured" }, { status: 503 });
}

// Taken from the raw URL so percent-encoding and the query string survive.
const { pathname, search } = request.nextUrl;
const target = `${API_URL}${pathname.replace(/^\/api(?=\/|$)/, "")}${search}`;

const headers = new Headers();
for (const name of FORWARDED_REQUEST_HEADERS) {
const value = request.headers.get(name);
if (value) headers.set(name, value);
}

const contentType = request.headers.get("content-type") ?? "";
const init: RequestInit & { duplex?: string } = {
method: request.method,
headers: { "content-type": contentType },
headers,
cache: "no-store",
// Closing the tab cancels the upstream call, which is what ends a live apply session.
signal: request.signal,
};

if (request.method !== "GET" && request.method !== "HEAD") {
if (request.method !== "GET" && request.method !== "HEAD" && request.body) {
init.duplex = "half";
init.body = request.body;
}

const upstream = await fetch(`${API_URL}/${path}`, init);
let upstream: Response;
try {
upstream = await fetch(target, init);
} catch {
if (request.signal.aborted) {
return new NextResponse(null, { status: 499 });
}
return NextResponse.json({ detail: "API unreachable" }, { status: 502 });
}

const responseHeaders = new Headers(upstream.headers);
for (const name of DROPPED_RESPONSE_HEADERS) {
responseHeaders.delete(name);
}

return new NextResponse(upstream.body, {
status: upstream.status,
headers: upstream.headers,
headers: responseHeaders,
});
}

export async function GET(
req: NextRequest,
{ params }: { params: { path: string[] } }
) {
return proxy(req, params.path.join("/"));
}

export async function POST(
req: NextRequest,
{ params }: { params: { path: string[] } }
) {
return proxy(req, params.path.join("/"));
}
export {
proxy as GET,
proxy as HEAD,
proxy as POST,
proxy as PUT,
proxy as PATCH,
proxy as DELETE,
};
Loading