import type { NextRequest } from "next/server"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; const PROXY_PATH_PARAM = "__proxyPath"; const REQUEST_HEADERS_TO_FORWARD = [ "accept", "accept-language", "authorization", "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" || (process.env.LOG_API_PROXY !== "false" && process.env.NODE_ENV !== "production"); 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 getRequestHeaders(request: NextRequest, targetUrl: URL) { const headers = new Headers(); const authKey = process.env.NEXT_PUBLIC_AUTH_KEY; for (const header of REQUEST_HEADERS_TO_FORWARD) { const value = request.headers.get(header); if (value) { headers.set(header, value); } } if (authKey) { headers.set("authorization", `token ${authKey}`); } headers.set("accept-encoding", "identity"); headers.set("http_x_user_language", "en"); headers.set("origin", targetUrl.origin); headers.set("platform", "android"); headers.set("referer", `${targetUrl.origin}/`); headers.set("user-agent", "dart:io"); 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, responseBody: ArrayBuffer, ) { if (!shouldLogProxy) { return; } writeProxyLog("response", { status: upstreamResponse.status, statusText: upstreamResponse.statusText, headers: headersToObject(upstreamResponse.headers), body: getBodyLogValue( responseBody, upstreamResponse.headers.get("content-type") ?? undefined, ), response: { status: upstreamResponse.status, statusText: upstreamResponse.statusText, headers: headersToObject(upstreamResponse.headers), body: getBodyLogValue( responseBody, upstreamResponse.headers.get("content-type") ?? undefined, ), }, }); } function writeProxyLog(label: string, value: unknown) { console.log(`[api-proxy] ${label} ${JSON.stringify(value)}`); } async function proxyRequest(request: NextRequest) { try { 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 upstreamResponse = await fetch(targetUrl, { method: request.method, headers: requestHeaders, body: requestBody, cache: "no-store", }); const responseBody = await upstreamResponse.arrayBuffer(); logProxyResponse(upstreamResponse, responseBody); return new Response(responseBody, { status: upstreamResponse.status, statusText: upstreamResponse.statusText, headers: getResponseHeaders(upstreamResponse.headers), }); } 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, };