Files
PolyWeather/frontend/lib/api-proxy.ts
T

183 lines
5.3 KiB
TypeScript
Raw Normal View History

import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import {
applyAuthResponseCookies,
buildBackendRequestHeaders,
} from "@/lib/backend-auth";
import { buildCachedJsonResponse } from "@/lib/http-cache";
import {
finishProxyTimedResponse,
type ProxyTimer,
} from "@/lib/proxy-timing";
const PASSTHROUGH_UPSTREAM_STATUSES = new Set([
400,
401,
402,
403,
404,
409,
422,
429,
]);
function shouldExposeProxyErrorDetail() {
return (
process.env.NODE_ENV !== "production" ||
process.env.POLYWEATHER_EXPOSE_PROXY_ERROR_DETAIL === "true"
);
}
export function clientStatusFromUpstream(status: number) {
if (PASSTHROUGH_UPSTREAM_STATUSES.has(status)) {
return status;
}
return 502;
}
export function buildUpstreamErrorResponse(
upstreamStatus: number,
rawDetail: string,
options?: {
detailLimit?: number;
error?: string;
extraDebug?: Record<string, unknown>;
},
) {
const body: Record<string, unknown> = {
error: options?.error || "Upstream request failed",
upstream_status: upstreamStatus,
};
if (shouldExposeProxyErrorDetail()) {
body.detail = String(rawDetail || "").slice(0, options?.detailLimit ?? 300);
if (options?.extraDebug) {
body.proxy_debug = options.extraDebug;
}
}
return NextResponse.json(body, {
status: clientStatusFromUpstream(upstreamStatus),
});
}
export function buildProxyExceptionResponse(
error: unknown,
options: {
status?: number;
publicMessage: string;
extra?: Record<string, unknown>;
},
) {
const body: Record<string, unknown> = {
error: options.publicMessage,
...(options.extra || {}),
};
if (shouldExposeProxyErrorDetail()) {
body.detail = String(error);
}
return NextResponse.json(body, { status: options.status ?? 500 });
}
export async function proxyBackendJsonGet(
req: NextRequest,
options: {
cacheControl?: string;
2026-05-31 19:51:28 +08:00
cacheControlForData?: (data: unknown) => string | undefined;
conditionalResponse?: boolean;
detailLimit?: number;
error?: string;
fetchCache?: RequestCache;
includeSupabaseIdentity?: boolean;
publicMessage: string;
revalidateSeconds?: number;
signal?: AbortSignal;
statusOnException?: number;
timeoutPublicMessage?: string;
timing?: ProxyTimer;
url: string;
},
) {
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
const timing = options.timing;
try {
auth = await (timing
? timing.measure("auth_headers", () =>
buildBackendRequestHeaders(req, {
includeSupabaseIdentity: options.includeSupabaseIdentity ?? false,
}),
)
: buildBackendRequestHeaders(req, {
includeSupabaseIdentity: options.includeSupabaseIdentity ?? false,
}));
const res = await (timing
? timing.measure("backend_fetch", () =>
fetch(options.url, {
headers: auth!.headers,
...(options.fetchCache
? { cache: options.fetchCache }
: { next: { revalidate: options.revalidateSeconds ?? 30 } }),
signal: options.signal,
}),
)
: fetch(options.url, {
headers: auth.headers,
...(options.fetchCache
? { cache: options.fetchCache }
: { next: { revalidate: options.revalidateSeconds ?? 30 } }),
signal: options.signal,
}));
const backendServerTiming = res.headers.get("server-timing") || "";
if (!res.ok) {
const raw = await (timing
? timing.measure("backend_read", () => res.text())
: res.text());
const response = buildUpstreamErrorResponse(res.status, raw, {
detailLimit: options.detailLimit,
error: options.error,
});
const withCookies = applyAuthResponseCookies(response, auth.response);
return timing
? finishProxyTimedResponse(withCookies, timing, `upstream_${res.status}`, {
backendServerTiming,
})
: withCookies;
}
const data = await (timing
? timing.measure("backend_read", () => res.json())
: res.json());
2026-05-31 19:51:28 +08:00
const responseCacheControl =
options.cacheControlForData?.(data) ?? options.cacheControl;
const response =
2026-05-31 19:51:28 +08:00
responseCacheControl && options.conditionalResponse !== false
? buildCachedJsonResponse(req, data, responseCacheControl)
: NextResponse.json(data, {
2026-05-31 19:51:28 +08:00
headers: responseCacheControl
? { "Cache-Control": responseCacheControl }
: undefined,
});
const withCookies = applyAuthResponseCookies(response, auth.response);
return timing
? finishProxyTimedResponse(withCookies, timing, "ok", {
backendServerTiming,
})
: withCookies;
} catch (error) {
const timedOut = options.signal?.aborted === true;
const response = buildProxyExceptionResponse(error, {
publicMessage:
timedOut && options.timeoutPublicMessage
? options.timeoutPublicMessage
: options.publicMessage,
status: timedOut ? 504 : options.statusOnException,
});
const withCookies = auth ? applyAuthResponseCookies(response, auth.response) : response;
return timing
? finishProxyTimedResponse(withCookies, timing, timedOut ? "timeout" : "exception")
: withCookies;
}
}