2026-05-14 15:05:13 +08:00
|
|
|
import type { NextRequest } from "next/server";
|
2026-04-29 11:22:49 +08:00
|
|
|
import { NextResponse } from "next/server";
|
2026-05-14 15:05:13 +08:00
|
|
|
import {
|
|
|
|
|
applyAuthResponseCookies,
|
|
|
|
|
buildBackendRequestHeaders,
|
|
|
|
|
} from "@/lib/backend-auth";
|
|
|
|
|
import { buildCachedJsonResponse } from "@/lib/http-cache";
|
2026-05-31 18:58:57 +08:00
|
|
|
import {
|
|
|
|
|
finishProxyTimedResponse,
|
|
|
|
|
type ProxyTimer,
|
|
|
|
|
} from "@/lib/proxy-timing";
|
2026-04-29 11:22:49 +08:00
|
|
|
|
|
|
|
|
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 });
|
|
|
|
|
}
|
2026-05-14 15:05:13 +08:00
|
|
|
|
|
|
|
|
export async function proxyBackendJsonGet(
|
|
|
|
|
req: NextRequest,
|
|
|
|
|
options: {
|
|
|
|
|
cacheControl?: string;
|
|
|
|
|
conditionalResponse?: boolean;
|
|
|
|
|
detailLimit?: number;
|
|
|
|
|
error?: string;
|
|
|
|
|
fetchCache?: RequestCache;
|
|
|
|
|
includeSupabaseIdentity?: boolean;
|
|
|
|
|
publicMessage: string;
|
|
|
|
|
revalidateSeconds?: number;
|
|
|
|
|
signal?: AbortSignal;
|
|
|
|
|
statusOnException?: number;
|
|
|
|
|
timeoutPublicMessage?: string;
|
2026-05-31 18:58:57 +08:00
|
|
|
timing?: ProxyTimer;
|
2026-05-14 15:05:13 +08:00
|
|
|
url: string;
|
|
|
|
|
},
|
|
|
|
|
) {
|
|
|
|
|
let auth: Awaited<ReturnType<typeof buildBackendRequestHeaders>> | null = null;
|
2026-05-31 18:58:57 +08:00
|
|
|
const timing = options.timing;
|
2026-05-14 15:05:13 +08:00
|
|
|
try {
|
2026-05-31 18:58:57 +08:00
|
|
|
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") || "";
|
2026-05-14 15:05:13 +08:00
|
|
|
if (!res.ok) {
|
2026-05-31 18:58:57 +08:00
|
|
|
const raw = await (timing
|
|
|
|
|
? timing.measure("backend_read", () => res.text())
|
|
|
|
|
: res.text());
|
2026-05-14 15:05:13 +08:00
|
|
|
const response = buildUpstreamErrorResponse(res.status, raw, {
|
|
|
|
|
detailLimit: options.detailLimit,
|
|
|
|
|
error: options.error,
|
|
|
|
|
});
|
2026-05-31 18:58:57 +08:00
|
|
|
const withCookies = applyAuthResponseCookies(response, auth.response);
|
|
|
|
|
return timing
|
|
|
|
|
? finishProxyTimedResponse(withCookies, timing, `upstream_${res.status}`, {
|
|
|
|
|
backendServerTiming,
|
|
|
|
|
})
|
|
|
|
|
: withCookies;
|
2026-05-14 15:05:13 +08:00
|
|
|
}
|
|
|
|
|
|
2026-05-31 18:58:57 +08:00
|
|
|
const data = await (timing
|
|
|
|
|
? timing.measure("backend_read", () => res.json())
|
|
|
|
|
: res.json());
|
2026-05-14 15:05:13 +08:00
|
|
|
const response =
|
|
|
|
|
options.cacheControl && options.conditionalResponse !== false
|
|
|
|
|
? buildCachedJsonResponse(req, data, options.cacheControl)
|
|
|
|
|
: NextResponse.json(data, {
|
|
|
|
|
headers: options.cacheControl
|
|
|
|
|
? { "Cache-Control": options.cacheControl }
|
|
|
|
|
: undefined,
|
|
|
|
|
});
|
2026-05-31 18:58:57 +08:00
|
|
|
const withCookies = applyAuthResponseCookies(response, auth.response);
|
|
|
|
|
return timing
|
|
|
|
|
? finishProxyTimedResponse(withCookies, timing, "ok", {
|
|
|
|
|
backendServerTiming,
|
|
|
|
|
})
|
|
|
|
|
: withCookies;
|
2026-05-14 15:05:13 +08:00
|
|
|
} 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,
|
|
|
|
|
});
|
2026-05-31 18:58:57 +08:00
|
|
|
const withCookies = auth ? applyAuthResponseCookies(response, auth.response) : response;
|
|
|
|
|
return timing
|
|
|
|
|
? finishProxyTimedResponse(withCookies, timing, timedOut ? "timeout" : "exception")
|
|
|
|
|
: withCookies;
|
2026-05-14 15:05:13 +08:00
|
|
|
}
|
|
|
|
|
}
|