import type { NextRequest } from "next/server"; import { isLocale } from "@/translations/config"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; const PROXY_PATH_PARAM = "__proxyPath"; const REQUEST_HEADERS_TO_FORWARD = [ "accept", "accept-language", "authorization", "cookie", "token", "content-type", "x-csrf-token", "x-csrftoken", "x-requested-with", "x-xsrf-token", ]; const RESPONSE_HEADERS_TO_DROP = [ "connection", "content-encoding", "content-length", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade", ]; const MAX_LOG_BODY_LENGTH = 10_000; const shouldLogProxy = process.env.LOG_API_PROXY === "true"; const shouldLogProxyTiming = process.env.LOG_API_PROXY_TIMING === "true" || shouldLogProxy; class ProxyError extends Error { constructor( message: string, readonly status: number, ) { super(message); } } function getApiBaseUrl() { const apiBaseUrl = process.env.API_BASE_URL ?? process.env.NEXT_PUBLIC_API_BASE_URL; if (!apiBaseUrl) { throw new ProxyError( "API_BASE_URL or NEXT_PUBLIC_API_BASE_URL is required", 500, ); } try { return new URL(apiBaseUrl); } catch { throw new ProxyError("API base URL is invalid", 500); } } function getProxyPath(request: NextRequest) { const proxyPath = request.nextUrl.searchParams.get(PROXY_PATH_PARAM); if (!proxyPath) { throw new ProxyError("Proxy path is required", 400); } if (/^[a-z][a-z\d+\-.]*:/i.test(proxyPath) || proxyPath.startsWith("//")) { throw new ProxyError("Proxy path must be relative", 400); } return proxyPath.startsWith("/") ? proxyPath : `/${proxyPath}`; } function getTargetUrl(request: NextRequest) { const targetUrl = getApiBaseUrl(); const proxyUrl = new URL(getProxyPath(request), targetUrl.origin); const basePath = targetUrl.pathname.replace(/\/$/, ""); targetUrl.pathname = `${basePath}${proxyUrl.pathname}`; const searchParams = new URLSearchParams(proxyUrl.search); request.nextUrl.searchParams.forEach((value, key) => { if (key !== PROXY_PATH_PARAM) { searchParams.append(key, value); } }); targetUrl.search = searchParams.toString(); return targetUrl; } function getCookieValue(cookieHeader: string, name: string) { const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); const pattern = new RegExp(`(?:^|;\\s*)${escapedName}=([^;]*)`); const match = cookieHeader.match(pattern); return match ? decodeURIComponent(match[1]) : null; } function getRequestHeaders(request: NextRequest, targetUrl: URL) { const headers = new Headers(); const authKey = process.env.NEXT_PUBLIC_AUTH_KEY; const cookieHeader = request.headers.get("cookie") ?? ""; for (const header of REQUEST_HEADERS_TO_FORWARD) { const value = request.headers.get(header); if (value) { if (header === "token") { // Convert token header to Authorization headers.set("authorization", `Token ${value}`); } else { headers.set(header, value); } } } // Prefer a cookie-based token when the browser session is anonymous. if (!headers.has("authorization")) { const cookieToken = getCookieValue(cookieHeader, "HABIB_TOKEN") ?? getCookieValue(cookieHeader, "habib_token") ?? getCookieValue(cookieHeader, "token") ?? getCookieValue(cookieHeader, "auth_token"); if ( cookieToken && cookieToken !== "NO_TOKEN" && cookieToken.trim() !== "" ) { headers.set("authorization", `Token ${cookieToken}`); } } // Override with authKey if set and no authorization header exists. if (authKey && !headers.has("authorization")) { headers.set("authorization", `Token ${authKey}`); } // Dynamically set language headers const requestedLanguages = [ request.headers.get("x-user-language"), getCookieValue(cookieHeader, "HABIB_LANGUAGE"), getCookieValue(cookieHeader, "habib_language"), request.headers.get("accept-language")?.split(",")[0]?.split("-")[0], ]; const lang = requestedLanguages.find( (candidate): candidate is string => candidate !== null && isLocale(candidate), ) ?? "en"; headers.set("accept-encoding", "gzip, br"); headers.set("accept-language", lang); headers.set("x-user-language", lang); headers.set("http_x_user_language", lang); headers.set("origin", targetUrl.origin); const clientUserAgent = request.headers.get("user-agent"); headers.set("user-agent", clientUserAgent || "dart:io"); // Forward client IP headers so backend can determine actual user location const clientIp = request.headers.get("cf-connecting-ip") || request.headers.get("x-real-ip") || request.headers.get("x-forwarded-for")?.split(",")[0]?.trim(); if (clientIp) { headers.set("x-forwarded-for", clientIp); headers.set("x-real-ip", clientIp); headers.set("x-client-ip", clientIp); } return headers; } function getResponseHeaders(upstreamHeaders: Headers) { const headers = new Headers(upstreamHeaders); for (const header of RESPONSE_HEADERS_TO_DROP) { headers.delete(header); } const setCookieHeaders = ( upstreamHeaders as Headers & { getSetCookie?: () => string[] } ).getSetCookie?.() ?? []; if (setCookieHeaders.length > 0) { headers.delete("set-cookie"); for (const cookie of setCookieHeaders) { headers.append("set-cookie", cookie); } } return headers; } function getBodyLogValue(body: ArrayBuffer | undefined, contentType?: string) { if (!body || body.byteLength === 0) { return null; } if (contentType && !isTextContentType(contentType)) { return `[${body.byteLength} bytes; ${contentType}]`; } const text = new TextDecoder().decode(body); if (text.length <= MAX_LOG_BODY_LENGTH) { return parseJsonForLog(text, text); } return `${text.slice(0, MAX_LOG_BODY_LENGTH)}... [truncated ${text.length - MAX_LOG_BODY_LENGTH} chars]`; } function isTextContentType(contentType: string) { return ( contentType.includes("application/json") || contentType.includes("application/problem+json") || contentType.startsWith("text/") || contentType.includes("+json") || contentType.includes("+xml") ); } function parseJsonForLog(text: string, fallback: string) { try { return JSON.parse(text); } catch { return fallback; } } function headersToObject(headers: Headers) { return Object.fromEntries(headers.entries()); } function logProxyRequest( request: NextRequest, targetUrl: URL, requestHeaders: Headers, requestBody: ArrayBuffer | undefined, ) { if (!shouldLogProxy) { return; } writeProxyLog("request", { incomingRequest: { method: request.method, url: request.url, nextUrl: request.nextUrl.toString(), headers: headersToObject(request.headers), body: getBodyLogValue( requestBody, request.headers.get("content-type") ?? undefined, ), }, upstreamRequest: { method: request.method, url: targetUrl.toString(), headers: headersToObject(requestHeaders), body: getBodyLogValue( requestBody, requestHeaders.get("content-type") ?? undefined, ), }, payload: getBodyLogValue( requestBody, request.headers.get("content-type") ?? undefined, ), }); } function logProxyResponse( upstreamResponse: Response, metrics: Record, ) { if (!shouldLogProxyTiming) { return; } writeProxyLog("response", { status: upstreamResponse.status, statusText: upstreamResponse.statusText, headers: headersToObject(upstreamResponse.headers), metrics, }); } function writeProxyLog(label: string, value: unknown) { console.log(`[api-proxy] ${label} ${JSON.stringify(value)}`); } async function proxyRequest(request: NextRequest) { try { const proxyStartedAt = performance.now(); const targetUrl = getTargetUrl(request); const requestBody = request.method === "GET" || request.method === "HEAD" ? undefined : await request.arrayBuffer(); const requestHeaders = getRequestHeaders(request, targetUrl); logProxyRequest(request, targetUrl, requestHeaders, requestBody); const upstreamStartedAt = performance.now(); const upstreamResponse = await fetch(targetUrl, { method: request.method, headers: requestHeaders, body: requestBody, cache: "no-store", }); const upstreamTtfb = performance.now() - upstreamStartedAt; const responseHeaders = getResponseHeaders(upstreamResponse.headers); // Force no caching on API proxy responses – always fetch fresh data responseHeaders.set( "Cache-Control", "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0", ); responseHeaders.set("Pragma", "no-cache"); responseHeaders.set("Expires", "0"); responseHeaders.set( "Server-Timing", `upstream-ttfb;dur=${upstreamTtfb.toFixed(1)}`, ); responseHeaders.set("X-Proxy-Upstream-TTFB-Ms", upstreamTtfb.toFixed(1)); let responseBytes = 0; const responseBody = upstreamResponse.body?.pipeThrough( new TransformStream({ transform(chunk, controller) { responseBytes += chunk.byteLength; controller.enqueue(chunk); }, flush() { const total = performance.now() - proxyStartedAt; logProxyResponse(upstreamResponse, { upstreamTtfb: Math.round(upstreamTtfb), upstreamBody: Math.round(total - upstreamTtfb), proxyTotal: Math.round(total), responseBytes, }); }, }), ) ?? null; return new Response(responseBody, { status: upstreamResponse.status, statusText: upstreamResponse.statusText, headers: responseHeaders, }); } catch (error) { if (error instanceof ProxyError) { return Response.json({ error: error.message }, { status: error.status }); } console.error("API proxy request failed", error); return Response.json( { error: "API proxy request failed" }, { status: 502 }, ); } } export { proxyRequest as DELETE, proxyRequest as GET, proxyRequest as HEAD, proxyRequest as OPTIONS, proxyRequest as PATCH, proxyRequest as POST, proxyRequest as PUT, };