diff --git a/tests/unit/test_next_backend_proxy_contracts.py b/tests/unit/test_next_backend_proxy_contracts.py new file mode 100644 index 00000000..2a0082f7 --- /dev/null +++ b/tests/unit/test_next_backend_proxy_contracts.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[2] +API_ROOT = ROOT / "web" / "src" / "app" / "api" + + +def _route_files() -> list[Path]: + return sorted( + path + for path in API_ROOT.rglob("route.ts") + if "_utils" not in path.parts + ) + + +def test_next_api_routes_use_shared_backend_proxy_helpers() -> None: + offenders: list[str] = [] + + for file_path in _route_files(): + text = file_path.read_text(encoding="utf-8") + rel = file_path.relative_to(ROOT) + + if "fetch(" in text: + offenders.append(f"{rel}: direct fetch") + + if "process.env.PAPERBOT_API_BASE_URL" in text or "process.env.BACKEND_BASE_URL" in text: + offenders.append(f"{rel}: inlined backend base URL") + + assert not offenders, "\n".join(offenders) + + +def test_dead_runbook_proxy_routes_are_removed() -> None: + assert not (API_ROOT / "runbook" / "runs" / "[runId]" / "route.ts").exists() + assert not (API_ROOT / "runbook" / "smoke" / "route.ts").exists() diff --git a/web/src/app/api/_utils/auth-headers.ts b/web/src/app/api/_utils/auth-headers.ts index 7d08f618..08c8132c 100644 --- a/web/src/app/api/_utils/auth-headers.ts +++ b/web/src/app/api/_utils/auth-headers.ts @@ -1,7 +1,11 @@ import { auth } from "@/auth" export function backendBaseUrl(): string { - return process.env.BACKEND_BASE_URL || "http://127.0.0.1:8000" + return ( + process.env.BACKEND_BASE_URL || + process.env.PAPERBOT_API_BASE_URL || + "http://127.0.0.1:8000" + ) } export async function withBackendAuth( @@ -35,4 +39,3 @@ export async function withBackendAuth( } return headers } - diff --git a/web/src/app/api/_utils/backend-proxy.test.ts b/web/src/app/api/_utils/backend-proxy.test.ts new file mode 100644 index 00000000..037e0971 --- /dev/null +++ b/web/src/app/api/_utils/backend-proxy.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { + apiBaseUrl, + proxyBinary, + proxyJson, + proxyStream, + proxyText, +} from "./backend-proxy" +import { withBackendAuth } from "./auth-headers" + +vi.mock("./auth-headers", () => ({ + backendBaseUrl: () => "http://backend.test", + withBackendAuth: vi.fn(async (_req: Request, base: HeadersInit = {}) => { + const headers = new Headers(base) + headers.set("authorization", "Bearer test-token") + return headers + }), +})) + +describe("backend-proxy", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it("uses the shared backend base URL", () => { + expect(apiBaseUrl()).toBe("http://backend.test") + }) + + it("proxies JSON with backend auth when requested", async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ ok: true }), { + status: 201, + headers: { "content-type": "application/json" }, + }), + ) + + const req = new Request("http://app.test/api/auth/me", { + body: JSON.stringify({ display_name: "PaperBot" }), + headers: { "content-type": "application/json" }, + method: "PATCH", + }) + + const res = await proxyJson(req, "http://backend.test/api/auth/me", "PATCH", { + auth: true, + }) + + expect(withBackendAuth).toHaveBeenCalledTimes(1) + expect(fetch).toHaveBeenCalledTimes(1) + const [, init] = vi.mocked(fetch).mock.calls[0] + const headers = new Headers((init as RequestInit).headers) + expect(headers.get("authorization")).toBe("Bearer test-token") + expect(await res.json()).toEqual({ ok: true }) + }) + + it("supports custom error handlers for text responses", async () => { + vi.mocked(fetch).mockRejectedValueOnce(new Error("boom")) + + const res = await proxyText( + new Request("http://app.test/api/newsletter/unsubscribe/token"), + "http://backend.test/api/newsletter/unsubscribe/token", + "GET", + { + onError: () => new Response("fallback", { + headers: { "Content-Type": "text/html" }, + status: 502, + }), + responseContentType: "text/html", + }, + ) + + expect(res.status).toBe(502) + expect(res.headers.get("content-type")).toContain("text/html") + expect(await res.text()).toContain("fallback") + }) + + it("preserves binary download headers", async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response(new Uint8Array([1, 2, 3]), { + headers: { + "content-disposition": 'attachment; filename="papers.csv"', + "content-type": "text/csv", + }, + status: 200, + }), + ) + + const res = await proxyBinary( + new Request("http://app.test/api/papers/export"), + "http://backend.test/api/research/papers/export", + "GET", + { accept: "*/*" }, + ) + + expect(res.headers.get("content-disposition")).toContain("papers.csv") + expect(res.headers.get("content-type")).toContain("text/csv") + }) + + it("passes through SSE streams and JSON fallbacks", async () => { + vi.mocked(fetch) + .mockResolvedValueOnce( + new Response("data: ping\n\n", { + headers: { "content-type": "text/event-stream" }, + status: 200, + }), + ) + .mockResolvedValueOnce( + new Response(JSON.stringify({ done: true }), { + headers: { "content-type": "application/json" }, + status: 200, + }), + ) + + const streamReq = new Request("http://app.test/api/research/paperscool/daily", { + body: JSON.stringify({}), + headers: { "content-type": "application/json" }, + method: "POST", + }) + const streamRes = await proxyStream( + streamReq, + "http://backend.test/api/research/paperscool/daily", + "POST", + { + auth: true, + passthroughNonStreamResponse: true, + responseContentType: "text/event-stream", + }, + ) + expect(streamRes.headers.get("content-type")).toContain("text/event-stream") + expect(await streamRes.text()).toContain("data: ping") + + const jsonReq = new Request("http://app.test/api/research/paperscool/daily", { + body: JSON.stringify({}), + headers: { "content-type": "application/json" }, + method: "POST", + }) + const jsonRes = await proxyStream( + jsonReq, + "http://backend.test/api/research/paperscool/daily", + "POST", + { + auth: true, + passthroughNonStreamResponse: true, + responseContentType: "text/event-stream", + }, + ) + expect(jsonRes.headers.get("content-type")).toContain("application/json") + expect(await jsonRes.json()).toEqual({ done: true }) + }) +}) diff --git a/web/src/app/api/_utils/backend-proxy.ts b/web/src/app/api/_utils/backend-proxy.ts new file mode 100644 index 00000000..e4144381 --- /dev/null +++ b/web/src/app/api/_utils/backend-proxy.ts @@ -0,0 +1,266 @@ +import { Agent } from "undici" + +import { backendBaseUrl, withBackendAuth } from "./auth-headers" + +export type ProxyMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE" + +type ProxyErrorContext = { + error: unknown + isTimeout: boolean + upstreamUrl: string +} + +type ProxyOptions = { + accept?: string + auth?: boolean + cache?: RequestCache + contentType?: string + onError?: (context: ProxyErrorContext) => Response + timeoutMs?: number +} + +type TextProxyOptions = ProxyOptions & { + responseContentType?: string + responseHeaders?: HeadersInit +} + +type BinaryProxyOptions = ProxyOptions & { + responseHeaders?: HeadersInit +} + +type StreamProxyOptions = ProxyOptions & { + dispatcher?: Agent + passthroughNonStreamResponse?: boolean + responseContentType?: string +} + +const DEFAULT_TIMEOUT_MS = 120_000 +const SSE_DISPATCHER = new Agent({ + bodyTimeout: 0, + headersTimeout: 0, +}) + +export function apiBaseUrl(): string { + return backendBaseUrl() +} + +export async function proxyJson( + req: Request, + upstreamUrl: string, + method: ProxyMethod, + options: TextProxyOptions = {}, +): Promise { + return proxyText(req, upstreamUrl, method, { + accept: options.accept ?? "application/json", + responseContentType: options.responseContentType ?? "application/json", + ...options, + }) +} + +export async function proxyText( + req: Request, + upstreamUrl: string, + method: ProxyMethod, + options: TextProxyOptions = {}, +): Promise { + try { + const upstream = await fetchUpstream(req, upstreamUrl, method, options) + const text = await upstream.text() + return buildTextResponse(text, upstream, { + responseContentType: options.responseContentType, + responseHeaders: options.responseHeaders, + }) + } catch (error) { + return handleProxyError(error, upstreamUrl, options.onError) + } +} + +export async function proxyBinary( + req: Request, + upstreamUrl: string, + method: ProxyMethod, + options: BinaryProxyOptions = {}, +): Promise { + try { + const upstream = await fetchUpstream(req, upstreamUrl, method, options) + const body = await upstream.arrayBuffer() + const headers = new Headers(options.responseHeaders) + headers.set("Cache-Control", "no-cache") + headers.set( + "Content-Type", + upstream.headers.get("content-type") || "application/octet-stream", + ) + + const contentDisposition = upstream.headers.get("content-disposition") + if (contentDisposition) { + headers.set("Content-Disposition", contentDisposition) + } + + return new Response(body, { + status: upstream.status, + headers, + }) + } catch (error) { + return handleProxyError(error, upstreamUrl, options.onError) + } +} + +export async function proxyStream( + req: Request, + upstreamUrl: string, + method: ProxyMethod, + options: StreamProxyOptions = {}, +): Promise { + try { + const upstream = await fetchUpstream(req, upstreamUrl, method, options, { + dispatcher: options.dispatcher ?? SSE_DISPATCHER, + }) + const upstreamContentType = upstream.headers.get("content-type") || "" + + if ( + options.passthroughNonStreamResponse && + !upstreamContentType.includes("text/event-stream") + ) { + const text = await upstream.text() + return buildTextResponse(text, upstream, { + responseContentType: options.responseContentType ?? "application/json", + }) + } + + const headers = new Headers() + headers.set( + "Content-Type", + upstreamContentType || options.responseContentType || "text/event-stream", + ) + headers.set("Cache-Control", "no-cache") + headers.set("Connection", "keep-alive") + + return new Response(upstream.body, { + status: upstream.status, + headers, + }) + } catch (error) { + return handleProxyError(error, upstreamUrl, options.onError) + } +} + +async function fetchUpstream( + req: Request, + upstreamUrl: string, + method: ProxyMethod, + options: ProxyOptions, + init: RequestInit & { dispatcher?: Agent } = {}, +): Promise { + const controller = options.timeoutMs === 0 ? null : new AbortController() + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + const timeout = controller + ? setTimeout(() => controller.abort(), timeoutMs) + : null + + try { + return await fetch(upstreamUrl, { + ...init, + method, + headers: await resolveHeaders(req, method, options), + body: await resolveBody(req, method), + cache: options.cache, + signal: controller?.signal, + } as RequestInit & { dispatcher?: Agent }) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} + +async function resolveHeaders( + req: Request, + method: ProxyMethod, + options: ProxyOptions, +): Promise { + const headers: Record = {} + + if (options.accept) { + headers.Accept = options.accept + } + + if (method !== "GET") { + headers["Content-Type"] = + options.contentType || + req.headers.get("content-type") || + "application/json" + } + + if (options.auth) { + return withBackendAuth(req, headers) + } + + return headers +} + +async function resolveBody( + req: Request, + method: ProxyMethod, +): Promise { + if (method === "GET") { + return undefined + } + + return req.text() +} + +function buildTextResponse( + text: string, + upstream: Response, + options: Pick, +): Response { + const headers = new Headers(options.responseHeaders) + headers.set("Cache-Control", "no-cache") + + const contentType = + upstream.headers.get("content-type") || options.responseContentType + + if (contentType && upstream.status !== 204 && text.length > 0) { + headers.set("Content-Type", contentType) + } + + if (upstream.status === 204 || text.length === 0) { + return new Response(null, { + status: upstream.status, + headers, + }) + } + + return new Response(text, { + status: upstream.status, + headers, + }) +} + +function handleProxyError( + error: unknown, + upstreamUrl: string, + onError?: (context: ProxyErrorContext) => Response, +): Response { + const isTimeout = error instanceof Error && error.name === "AbortError" + const context = { + error, + isTimeout, + upstreamUrl, + } + + if (onError) { + return onError(context) + } + + const detail = error instanceof Error ? error.message : String(error) + return Response.json( + { + detail: isTimeout + ? `Upstream API timed out: ${upstreamUrl}` + : `Upstream API unreachable: ${upstreamUrl}`, + error: detail, + }, + { status: 502 }, + ) +} diff --git a/web/src/app/api/auth/forgot-password/route.ts b/web/src/app/api/auth/forgot-password/route.ts index 7135b7de..1a54fdc0 100644 --- a/web/src/app/api/auth/forgot-password/route.ts +++ b/web/src/app/api/auth/forgot-password/route.ts @@ -1,13 +1,5 @@ -import { NextRequest, NextResponse } from "next/server" -import { backendBaseUrl } from "@/app/api/_utils/auth-headers" +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" -export async function POST(req: NextRequest) { - const body = await req.json() - const res = await fetch(`${backendBaseUrl()}/api/auth/forgot-password`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }) - const data = await res.json().catch(() => null) - return NextResponse.json(data, { status: res.status }) +export async function POST(req: Request) { + return proxyJson(req, `${apiBaseUrl()}/api/auth/forgot-password`, "POST") } diff --git a/web/src/app/api/auth/login-check/route.ts b/web/src/app/api/auth/login-check/route.ts index 410c4bb0..357e7c66 100644 --- a/web/src/app/api/auth/login-check/route.ts +++ b/web/src/app/api/auth/login-check/route.ts @@ -1,19 +1,9 @@ export const runtime = "nodejs" -import { backendBaseUrl } from "../../_utils/auth-headers" +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" export async function POST(req: Request) { - const body = await req.text() - const res = await fetch(`${backendBaseUrl()}/api/auth/login`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body, - }).catch(() => null) - - if (!res) return Response.json({ detail: "Service unavailable" }, { status: 502 }) - const text = await res.text() - return new Response(text, { - status: res.status, - headers: { "Content-Type": "application/json" }, + return proxyJson(req, `${apiBaseUrl()}/api/auth/login`, "POST", { + onError: () => Response.json({ detail: "Service unavailable" }, { status: 502 }), }) } diff --git a/web/src/app/api/auth/me/change-password/route.ts b/web/src/app/api/auth/me/change-password/route.ts index e593324f..01235d59 100644 --- a/web/src/app/api/auth/me/change-password/route.ts +++ b/web/src/app/api/auth/me/change-password/route.ts @@ -1,14 +1,7 @@ -import { NextRequest, NextResponse } from "next/server" -import { backendBaseUrl, withBackendAuth } from "@/app/api/_utils/auth-headers" +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" -export async function POST(req: NextRequest) { - const body = await req.json() - const headers = await withBackendAuth(req, { "Content-Type": "application/json" }) - const res = await fetch(`${backendBaseUrl()}/api/auth/me/change-password`, { - method: "POST", - headers, - body: JSON.stringify(body), +export async function POST(req: Request) { + return proxyJson(req, `${apiBaseUrl()}/api/auth/me/change-password`, "POST", { + auth: true, }) - const data = await res.json().catch(() => null) - return NextResponse.json(data, { status: res.status }) } diff --git a/web/src/app/api/auth/me/route.ts b/web/src/app/api/auth/me/route.ts index f74601f2..fe441a69 100644 --- a/web/src/app/api/auth/me/route.ts +++ b/web/src/app/api/auth/me/route.ts @@ -1,27 +1,13 @@ -import { NextRequest, NextResponse } from "next/server" -import { backendBaseUrl, withBackendAuth } from "@/app/api/_utils/auth-headers" +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" -export async function GET(req: NextRequest) { - const headers = await withBackendAuth(req) - const res = await fetch(`${backendBaseUrl()}/api/auth/me`, { headers }) - const data = await res.json().catch(() => null) - return NextResponse.json(data, { status: res.status }) +export async function GET(req: Request) { + return proxyJson(req, `${apiBaseUrl()}/api/auth/me`, "GET", { auth: true }) } -export async function PATCH(req: NextRequest) { - const body = await req.json() - const headers = await withBackendAuth(req, { "Content-Type": "application/json" }) - const res = await fetch(`${backendBaseUrl()}/api/auth/me`, { - method: "PATCH", - headers, - body: JSON.stringify(body), - }) - const data = await res.json().catch(() => null) - return NextResponse.json(data, { status: res.status }) +export async function PATCH(req: Request) { + return proxyJson(req, `${apiBaseUrl()}/api/auth/me`, "PATCH", { auth: true }) } -export async function DELETE(req: NextRequest) { - const headers = await withBackendAuth(req) - const res = await fetch(`${backendBaseUrl()}/api/auth/me`, { method: "DELETE", headers }) - return new NextResponse(null, { status: res.status }) +export async function DELETE(req: Request) { + return proxyJson(req, `${apiBaseUrl()}/api/auth/me`, "DELETE", { auth: true }) } diff --git a/web/src/app/api/auth/register/route.ts b/web/src/app/api/auth/register/route.ts index bfea4723..51f069c2 100644 --- a/web/src/app/api/auth/register/route.ts +++ b/web/src/app/api/auth/register/route.ts @@ -1,13 +1,5 @@ -import { NextRequest, NextResponse } from "next/server" -import { backendBaseUrl } from "@/app/api/_utils/auth-headers" +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" -export async function POST(req: NextRequest) { - const body = await req.json() - const res = await fetch(`${backendBaseUrl()}/api/auth/register`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }) - const data = await res.json().catch(() => null) - return NextResponse.json(data, { status: res.status }) +export async function POST(req: Request) { + return proxyJson(req, `${apiBaseUrl()}/api/auth/register`, "POST") } diff --git a/web/src/app/api/auth/reset-password/route.ts b/web/src/app/api/auth/reset-password/route.ts index 25e68124..8bc8e77e 100644 --- a/web/src/app/api/auth/reset-password/route.ts +++ b/web/src/app/api/auth/reset-password/route.ts @@ -1,13 +1,5 @@ -import { NextRequest, NextResponse } from "next/server" -import { backendBaseUrl } from "@/app/api/_utils/auth-headers" +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" -export async function POST(req: NextRequest) { - const body = await req.json() - const res = await fetch(`${backendBaseUrl()}/api/auth/reset-password`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }) - const data = await res.json().catch(() => null) - return NextResponse.json(data, { status: res.status }) +export async function POST(req: Request) { + return proxyJson(req, `${apiBaseUrl()}/api/auth/reset-password`, "POST") } diff --git a/web/src/app/api/gen-code/route.ts b/web/src/app/api/gen-code/route.ts index c5f78144..2768f07c 100644 --- a/web/src/app/api/gen-code/route.ts +++ b/web/src/app/api/gen-code/route.ts @@ -1,28 +1,11 @@ export const runtime = "nodejs" -function apiBaseUrl() { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" -} - -import { withBackendAuth } from "../_utils/auth-headers" +import { apiBaseUrl, proxyStream } from "@/app/api/_utils/backend-proxy" export async function POST(req: Request) { - const body = await req.text() - const upstream = await fetch(`${apiBaseUrl()}/api/gen-code`, { - method: "POST", - headers: await withBackendAuth(req, { - "Content-Type": req.headers.get("content-type") || "application/json", - }), - body, - }) - - const headers = new Headers() - headers.set("Content-Type", upstream.headers.get("content-type") || "text/event-stream") - headers.set("Cache-Control", "no-cache") - headers.set("Connection", "keep-alive") - - return new Response(upstream.body, { - status: upstream.status, - headers, + return proxyStream(req, `${apiBaseUrl()}/api/gen-code`, "POST", { + accept: "text/event-stream", + auth: true, + responseContentType: "text/event-stream", }) } diff --git a/web/src/app/api/newsletter/unsubscribe/[token]/route.ts b/web/src/app/api/newsletter/unsubscribe/[token]/route.ts index c644c2b3..96d4bddb 100644 --- a/web/src/app/api/newsletter/unsubscribe/[token]/route.ts +++ b/web/src/app/api/newsletter/unsubscribe/[token]/route.ts @@ -1,29 +1,31 @@ export const runtime = "nodejs" -import { apiBaseUrl } from "../../../research/_base" +import { apiBaseUrl, proxyText } from "@/app/api/_utils/backend-proxy" export async function GET( - _req: Request, + req: Request, { params }: { params: Promise<{ token: string }> }, ) { const { token } = await params - try { - const upstream = await fetch( - `${apiBaseUrl()}/api/newsletter/unsubscribe/${encodeURIComponent(token)}`, - ) - const text = await upstream.text() - return new Response(text, { - status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") || "text/html", + return proxyText( + req, + `${apiBaseUrl()}/api/newsletter/unsubscribe/${encodeURIComponent(token)}`, + "GET", + { + accept: "text/html", + responseContentType: "text/html", + onError: ({ error }) => { + const detail = error instanceof Error ? error.message : String(error) + const escaped = detail + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + return new Response( + `

Error

${escaped}

`, + { status: 502, headers: { "Content-Type": "text/html" } }, + ) }, - }) - } catch (error) { - const detail = error instanceof Error ? error.message : String(error) - const escaped = detail.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """) - return new Response( - `

Error

${escaped}

`, - { status: 502, headers: { "Content-Type": "text/html" } }, - ) - } + }, + ) } diff --git a/web/src/app/api/papers/export/route.ts b/web/src/app/api/papers/export/route.ts index 92bedde1..662cc685 100644 --- a/web/src/app/api/papers/export/route.ts +++ b/web/src/app/api/papers/export/route.ts @@ -1,30 +1,13 @@ export const runtime = "nodejs" -import { apiBaseUrl } from "@/app/api/research/_base" +import { apiBaseUrl, proxyBinary } from "@/app/api/_utils/backend-proxy" export async function GET(req: Request) { const url = new URL(req.url) - const upstream = `${apiBaseUrl()}/api/research/papers/export?${url.searchParams.toString()}` - - try { - const res = await fetch(upstream, { - method: "GET", - headers: { Accept: "*/*" }, - }) - const body = await res.arrayBuffer() - return new Response(body, { - status: res.status, - headers: { - "Content-Type": res.headers.get("content-type") || "application/octet-stream", - "Content-Disposition": res.headers.get("content-disposition") || "", - "Cache-Control": "no-cache", - }, - }) - } catch (error) { - const detail = error instanceof Error ? error.message : String(error) - return Response.json( - { detail: `Upstream API unreachable`, error: detail }, - { status: 502 }, - ) - } + return proxyBinary( + req, + `${apiBaseUrl()}/api/research/papers/export?${url.searchParams.toString()}`, + "GET", + { accept: "*/*" }, + ) } diff --git a/web/src/app/api/research/_base.ts b/web/src/app/api/research/_base.ts index fad9f1a7..1d28863e 100644 --- a/web/src/app/api/research/_base.ts +++ b/web/src/app/api/research/_base.ts @@ -1,47 +1,15 @@ -export function apiBaseUrl() { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" -} +import { + apiBaseUrl, + proxyJson as proxyBackendJson, + type ProxyMethod, +} from "../_utils/backend-proxy" -export async function proxyJson(req: Request, upstreamUrl: string, method: string) { - const body = method === "GET" ? undefined : await req.text() - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 120_000) // 2 min timeout +export { apiBaseUrl } - try { - const baseHeaders = { - method, - Accept: "application/json", - "Content-Type": req.headers.get("content-type") || "application/json", - } as Record - const { withBackendAuth } = await import("../_utils/auth-headers") - const headers = await withBackendAuth(req, baseHeaders) - const upstream = await fetch(upstreamUrl, { - method, - headers, - body, - signal: controller.signal, - }) - const text = await upstream.text() - return new Response(text, { - status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") || "application/json", - "Cache-Control": "no-cache", - }, - }) - } catch (error) { - const detail = error instanceof Error ? error.message : String(error) - const isTimeout = error instanceof Error && error.name === "AbortError" - return Response.json( - { - detail: isTimeout - ? `Upstream API timed out: ${upstreamUrl}` - : `Upstream API unreachable: ${upstreamUrl}`, - error: detail, - }, - { status: 502 }, - ) - } finally { - clearTimeout(timeout) - } +export async function proxyJson( + req: Request, + upstreamUrl: string, + method: ProxyMethod, +) { + return proxyBackendJson(req, upstreamUrl, method, { auth: true }) } diff --git a/web/src/app/api/research/paperscool/analyze/route.ts b/web/src/app/api/research/paperscool/analyze/route.ts index 32c83ad2..367a1a2d 100644 --- a/web/src/app/api/research/paperscool/analyze/route.ts +++ b/web/src/app/api/research/paperscool/analyze/route.ts @@ -1,43 +1,10 @@ export const runtime = "nodejs" -import { Agent } from "undici" - -import { apiBaseUrl } from "../../_base" - -// Analyze can stream for a long time; disable body timeout on the proxy hop. -const sseDispatcher = new Agent({ - bodyTimeout: 0, - headersTimeout: 0, -}) +import { apiBaseUrl, proxyStream } from "@/app/api/_utils/backend-proxy" export async function POST(req: Request) { - const body = await req.text() - let upstream: Response - try { - upstream = await fetch(`${apiBaseUrl()}/api/research/paperscool/analyze`, { - method: "POST", - headers: { - "Content-Type": req.headers.get("content-type") || "application/json", - Accept: "text/event-stream", - }, - body, - dispatcher: sseDispatcher, - } as RequestInit & { dispatcher: Agent }) - } catch (error) { - const detail = error instanceof Error ? error.message : String(error) - return Response.json( - { detail: "Upstream API unreachable", error: detail }, - { status: 502 }, - ) - } - - const headers = new Headers() - headers.set("Content-Type", upstream.headers.get("content-type") || "text/event-stream") - headers.set("Cache-Control", "no-cache") - headers.set("Connection", "keep-alive") - - return new Response(upstream.body, { - status: upstream.status, - headers, + return proxyStream(req, `${apiBaseUrl()}/api/research/paperscool/analyze`, "POST", { + accept: "text/event-stream", + responseContentType: "text/event-stream", }) } diff --git a/web/src/app/api/research/paperscool/daily/route.ts b/web/src/app/api/research/paperscool/daily/route.ts index 3cce1e0b..8757066f 100644 --- a/web/src/app/api/research/paperscool/daily/route.ts +++ b/web/src/app/api/research/paperscool/daily/route.ts @@ -1,60 +1,12 @@ export const runtime = "nodejs" -import { Agent } from "undici" - -import { apiBaseUrl } from "../../_base" -import { withBackendAuth } from "../../../_utils/auth-headers" - -// Keep SSE proxy streams alive during long backend phases (LLM/Judge). -const sseDispatcher = new Agent({ - bodyTimeout: 0, - headersTimeout: 0, -}) +import { apiBaseUrl, proxyStream } from "@/app/api/_utils/backend-proxy" export async function POST(req: Request) { - const body = await req.text() - const contentType = req.headers.get("content-type") || "application/json" - - let upstream: Response - try { - upstream = await fetch(`${apiBaseUrl()}/api/research/paperscool/daily`, { - method: "POST", - headers: await withBackendAuth(req, { - "Content-Type": contentType, - Accept: "text/event-stream, application/json", - }), - body, - dispatcher: sseDispatcher, - } as RequestInit & { dispatcher: Agent }) - } catch (error) { - const detail = error instanceof Error ? error.message : String(error) - return Response.json( - { detail: "Upstream API unreachable", error: detail }, - { status: 502 }, - ) - } - - const upstreamContentType = upstream.headers.get("content-type") || "" - - // SSE stream path — pipe through without buffering - if (upstreamContentType.includes("text/event-stream")) { - return new Response(upstream.body, { - status: upstream.status, - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }) - } - - // JSON fallback (fast path when no LLM/Judge) - const text = await upstream.text() - return new Response(text, { - status: upstream.status, - headers: { - "Content-Type": upstreamContentType || "application/json", - "Cache-Control": "no-cache", - }, + return proxyStream(req, `${apiBaseUrl()}/api/research/paperscool/daily`, "POST", { + accept: "text/event-stream, application/json", + auth: true, + passthroughNonStreamResponse: true, + responseContentType: "text/event-stream", }) } diff --git a/web/src/app/api/research/paperscool/sessions/[sessionId]/route.ts b/web/src/app/api/research/paperscool/sessions/[sessionId]/route.ts index a7c51356..93e4470d 100644 --- a/web/src/app/api/research/paperscool/sessions/[sessionId]/route.ts +++ b/web/src/app/api/research/paperscool/sessions/[sessionId]/route.ts @@ -1,17 +1,14 @@ -import { NextResponse } from "next/server" +import { apiBaseUrl, proxyText } from "@/app/api/_utils/backend-proxy" -import { apiBaseUrl } from "@/app/api/research/_base" - -export async function GET(_req: Request, ctx: { params: Promise<{ sessionId: string }> }) { +export async function GET(req: Request, ctx: { params: Promise<{ sessionId: string }> }) { const { sessionId } = await ctx.params - const upstream = await fetch( + return proxyText( + req, `${apiBaseUrl()}/api/research/paperscool/sessions/${encodeURIComponent(sessionId)}`, - { cache: "no-store" }, + "GET", + { + cache: "no-store", + responseContentType: "application/json", + }, ) - - const body = await upstream.text() - return new NextResponse(body, { - status: upstream.status, - headers: { "Content-Type": upstream.headers.get("content-type") || "application/json" }, - }) } diff --git a/web/src/app/api/research/repro/context/route.ts b/web/src/app/api/research/repro/context/route.ts index 5f783cd3..b1a340ad 100644 --- a/web/src/app/api/research/repro/context/route.ts +++ b/web/src/app/api/research/repro/context/route.ts @@ -1,5 +1,5 @@ import { apiBaseUrl, proxyJson } from "../../_base" -import { withBackendAuth } from "../../../_utils/auth-headers" +import { proxyStream } from "@/app/api/_utils/backend-proxy" export const runtime = "nodejs" @@ -9,23 +9,9 @@ export async function GET(req: Request) { } export async function POST(req: Request) { - const body = await req.text() - const upstream = await fetch(`${apiBaseUrl()}/api/research/repro/context/generate`, { - method: "POST", - headers: await withBackendAuth(req, { - "Content-Type": req.headers.get("content-type") || "application/json", - Accept: "text/event-stream", - }), - body, - }) - - const headers = new Headers() - headers.set("Content-Type", upstream.headers.get("content-type") || "text/event-stream") - headers.set("Cache-Control", "no-cache") - headers.set("Connection", "keep-alive") - - return new Response(upstream.body, { - status: upstream.status, - headers, + return proxyStream(req, `${apiBaseUrl()}/api/research/repro/context/generate`, "POST", { + accept: "text/event-stream", + auth: true, + responseContentType: "text/event-stream", }) } diff --git a/web/src/app/api/runbook/allowed-dirs/route.ts b/web/src/app/api/runbook/allowed-dirs/route.ts index ce48b0fe..59e07bcb 100644 --- a/web/src/app/api/runbook/allowed-dirs/route.ts +++ b/web/src/app/api/runbook/allowed-dirs/route.ts @@ -1,39 +1,11 @@ export const runtime = "nodejs" -function apiBaseUrl() { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" -} +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" -export async function GET() { - const upstream = await fetch(`${apiBaseUrl()}/api/runbook/allowed-dirs`, { - headers: { Accept: "application/json" }, - }) - const text = await upstream.text() - return new Response(text, { - status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") || "application/json", - "Cache-Control": "no-cache", - }, - }) +export async function GET(req: Request) { + return proxyJson(req, `${apiBaseUrl()}/api/runbook/allowed-dirs`, "GET") } export async function POST(req: Request) { - const body = await req.text() - const upstream = await fetch(`${apiBaseUrl()}/api/runbook/allowed-dirs`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body, - }) - const text = await upstream.text() - return new Response(text, { - status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") || "application/json", - "Cache-Control": "no-cache", - }, - }) + return proxyJson(req, `${apiBaseUrl()}/api/runbook/allowed-dirs`, "POST") } diff --git a/web/src/app/api/runbook/changes/route.ts b/web/src/app/api/runbook/changes/route.ts index 5663d3f6..601b6165 100644 --- a/web/src/app/api/runbook/changes/route.ts +++ b/web/src/app/api/runbook/changes/route.ts @@ -1,22 +1,8 @@ export const runtime = "nodejs" -function apiBaseUrl() { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" -} +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" export async function GET(req: Request) { const url = new URL(req.url) - const upstream = await fetch(`${apiBaseUrl()}/api/runbook/changes?${url.searchParams.toString()}`, { - method: "GET", - headers: { Accept: "application/json" }, - }) - const text = await upstream.text() - return new Response(text, { - status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") || "application/json", - "Cache-Control": "no-cache", - }, - }) + return proxyJson(req, `${apiBaseUrl()}/api/runbook/changes?${url.searchParams.toString()}`, "GET") } - diff --git a/web/src/app/api/runbook/delete/route.ts b/web/src/app/api/runbook/delete/route.ts index 5f9ce689..0e19f5a6 100644 --- a/web/src/app/api/runbook/delete/route.ts +++ b/web/src/app/api/runbook/delete/route.ts @@ -1,25 +1,9 @@ export const runtime = "nodejs" -function apiBaseUrl() { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" -} +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" export async function POST(req: Request) { - const body = await req.text() - const upstream = await fetch(`${apiBaseUrl()}/api/runbook/delete`, { - method: "POST", - headers: await (await import("../../_utils/auth-headers")).withBackendAuth(req, { - "Content-Type": req.headers.get("content-type") || "application/json", - Accept: "application/json", - }), - body, - }) - const text = await upstream.text() - return new Response(text, { - status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") || "application/json", - "Cache-Control": "no-cache", - }, + return proxyJson(req, `${apiBaseUrl()}/api/runbook/delete`, "POST", { + auth: true, }) } diff --git a/web/src/app/api/runbook/diff/route.ts b/web/src/app/api/runbook/diff/route.ts index ad200efc..64a55c4a 100644 --- a/web/src/app/api/runbook/diff/route.ts +++ b/web/src/app/api/runbook/diff/route.ts @@ -1,22 +1,8 @@ export const runtime = "nodejs" -function apiBaseUrl() { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" -} +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" export async function GET(req: Request) { const url = new URL(req.url) - const upstream = await fetch(`${apiBaseUrl()}/api/runbook/diff?${url.searchParams.toString()}`, { - method: "GET", - headers: { Accept: "application/json" }, - }) - const text = await upstream.text() - return new Response(text, { - status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") || "application/json", - "Cache-Control": "no-cache", - }, - }) + return proxyJson(req, `${apiBaseUrl()}/api/runbook/diff?${url.searchParams.toString()}`, "GET") } - diff --git a/web/src/app/api/runbook/file/route.ts b/web/src/app/api/runbook/file/route.ts index 113be67e..25548068 100644 --- a/web/src/app/api/runbook/file/route.ts +++ b/web/src/app/api/runbook/file/route.ts @@ -1,42 +1,12 @@ export const runtime = "nodejs" -function apiBaseUrl() { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" -} +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" export async function GET(req: Request) { const url = new URL(req.url) - const upstream = await fetch(`${apiBaseUrl()}/api/runbook/file?${url.searchParams.toString()}`, { - method: "GET", - headers: { Accept: "application/json" }, - }) - const text = await upstream.text() - return new Response(text, { - status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") || "application/json", - "Cache-Control": "no-cache", - }, - }) + return proxyJson(req, `${apiBaseUrl()}/api/runbook/file?${url.searchParams.toString()}`, "GET") } export async function POST(req: Request) { - const body = await req.text() - const upstream = await fetch(`${apiBaseUrl()}/api/runbook/file`, { - method: "POST", - headers: { - "Content-Type": req.headers.get("content-type") || "application/json", - Accept: "application/json", - }, - body, - }) - const text = await upstream.text() - return new Response(text, { - status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") || "application/json", - "Cache-Control": "no-cache", - }, - }) + return proxyJson(req, `${apiBaseUrl()}/api/runbook/file`, "POST") } - diff --git a/web/src/app/api/runbook/files/route.ts b/web/src/app/api/runbook/files/route.ts index 9888a3f6..193eb9d3 100644 --- a/web/src/app/api/runbook/files/route.ts +++ b/web/src/app/api/runbook/files/route.ts @@ -1,22 +1,8 @@ export const runtime = "nodejs" -function apiBaseUrl() { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" -} +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" export async function GET(req: Request) { const url = new URL(req.url) - const upstream = await fetch(`${apiBaseUrl()}/api/runbook/files?${url.searchParams.toString()}`, { - method: "GET", - headers: { Accept: "application/json" }, - }) - const text = await upstream.text() - return new Response(text, { - status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") || "application/json", - "Cache-Control": "no-cache", - }, - }) + return proxyJson(req, `${apiBaseUrl()}/api/runbook/files?${url.searchParams.toString()}`, "GET") } - diff --git a/web/src/app/api/runbook/project-dir/prepare/route.ts b/web/src/app/api/runbook/project-dir/prepare/route.ts index cebeec42..fc536b7f 100644 --- a/web/src/app/api/runbook/project-dir/prepare/route.ts +++ b/web/src/app/api/runbook/project-dir/prepare/route.ts @@ -1,25 +1,7 @@ export const runtime = "nodejs" -function apiBaseUrl() { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" -} +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" export async function POST(req: Request) { - const body = await req.text() - const upstream = await fetch(`${apiBaseUrl()}/api/runbook/project-dir/prepare`, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body, - }) - const text = await upstream.text() - return new Response(text, { - status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") || "application/json", - "Cache-Control": "no-cache", - }, - }) + return proxyJson(req, `${apiBaseUrl()}/api/runbook/project-dir/prepare`, "POST") } diff --git a/web/src/app/api/runbook/revert-hunks/route.ts b/web/src/app/api/runbook/revert-hunks/route.ts index 397783db..bc22a41d 100644 --- a/web/src/app/api/runbook/revert-hunks/route.ts +++ b/web/src/app/api/runbook/revert-hunks/route.ts @@ -1,26 +1,7 @@ export const runtime = "nodejs" -function apiBaseUrl() { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" -} +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" export async function POST(req: Request) { - const body = await req.text() - const upstream = await fetch(`${apiBaseUrl()}/api/runbook/revert-hunks`, { - method: "POST", - headers: { - "Content-Type": req.headers.get("content-type") || "application/json", - Accept: "application/json", - }, - body, - }) - const text = await upstream.text() - return new Response(text, { - status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") || "application/json", - "Cache-Control": "no-cache", - }, - }) + return proxyJson(req, `${apiBaseUrl()}/api/runbook/revert-hunks`, "POST") } - diff --git a/web/src/app/api/runbook/revert-project/route.ts b/web/src/app/api/runbook/revert-project/route.ts index a1ecd7b5..f581096a 100644 --- a/web/src/app/api/runbook/revert-project/route.ts +++ b/web/src/app/api/runbook/revert-project/route.ts @@ -1,25 +1,9 @@ export const runtime = "nodejs" -function apiBaseUrl() { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" -} +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" export async function POST(req: Request) { - const body = await req.text() - const upstream = await fetch(`${apiBaseUrl()}/api/runbook/revert-project`, { - method: "POST", - headers: await (await import("../../_utils/auth-headers")).withBackendAuth(req, { - "Content-Type": req.headers.get("content-type") || "application/json", - Accept: "application/json", - }), - body, - }) - const text = await upstream.text() - return new Response(text, { - status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") || "application/json", - "Cache-Control": "no-cache", - }, + return proxyJson(req, `${apiBaseUrl()}/api/runbook/revert-project`, "POST", { + auth: true, }) } diff --git a/web/src/app/api/runbook/revert/route.ts b/web/src/app/api/runbook/revert/route.ts index 04ade1c1..50cbc547 100644 --- a/web/src/app/api/runbook/revert/route.ts +++ b/web/src/app/api/runbook/revert/route.ts @@ -1,26 +1,7 @@ export const runtime = "nodejs" -function apiBaseUrl() { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" -} +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" export async function POST(req: Request) { - const body = await req.text() - const upstream = await fetch(`${apiBaseUrl()}/api/runbook/revert`, { - method: "POST", - headers: { - "Content-Type": req.headers.get("content-type") || "application/json", - Accept: "application/json", - }, - body, - }) - const text = await upstream.text() - return new Response(text, { - status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") || "application/json", - "Cache-Control": "no-cache", - }, - }) + return proxyJson(req, `${apiBaseUrl()}/api/runbook/revert`, "POST") } - diff --git a/web/src/app/api/runbook/runs/[runId]/route.ts b/web/src/app/api/runbook/runs/[runId]/route.ts deleted file mode 100644 index 57678431..00000000 --- a/web/src/app/api/runbook/runs/[runId]/route.ts +++ /dev/null @@ -1,19 +0,0 @@ -export const runtime = "nodejs" - -function apiBaseUrl() { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" -} - -export async function GET(_req: Request, ctx: { params: Promise<{ runId: string }> }) { - const { runId } = await ctx.params - const upstream = await fetch(`${apiBaseUrl()}/api/runbook/runs/${encodeURIComponent(runId)}`, { method: "GET" }) - const text = await upstream.text() - return new Response(text, { - status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") || "application/json", - "Cache-Control": "no-cache", - }, - }) -} - diff --git a/web/src/app/api/runbook/smoke/route.ts b/web/src/app/api/runbook/smoke/route.ts deleted file mode 100644 index 19dde6bb..00000000 --- a/web/src/app/api/runbook/smoke/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -export const runtime = "nodejs" - -function apiBaseUrl() { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" -} - -export async function POST(req: Request) { - const body = await req.text() - const upstream = await fetch(`${apiBaseUrl()}/api/runbook/smoke`, { - method: "POST", - headers: await (await import("../../_utils/auth-headers")).withBackendAuth(req, { - "Content-Type": req.headers.get("content-type") || "application/json", - }), - body, - }) - - const text = await upstream.text() - return new Response(text, { - status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") || "application/json", - "Cache-Control": "no-cache", - }, - }) -} diff --git a/web/src/app/api/runbook/snapshots/[snapshotId]/route.ts b/web/src/app/api/runbook/snapshots/[snapshotId]/route.ts index 5dbfdd00..3e4e24e2 100644 --- a/web/src/app/api/runbook/snapshots/[snapshotId]/route.ts +++ b/web/src/app/api/runbook/snapshots/[snapshotId]/route.ts @@ -1,22 +1,12 @@ export const runtime = "nodejs" -function apiBaseUrl() { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" -} +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" -export async function GET(_req: Request, ctx: { params: Promise<{ snapshotId: string }> }) { +export async function GET(req: Request, ctx: { params: Promise<{ snapshotId: string }> }) { const { snapshotId } = await ctx.params - const upstream = await fetch(`${apiBaseUrl()}/api/runbook/snapshots/${encodeURIComponent(snapshotId)}`, { - method: "GET", - headers: { Accept: "application/json" }, - }) - const text = await upstream.text() - return new Response(text, { - status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") || "application/json", - "Cache-Control": "no-cache", - }, - }) + return proxyJson( + req, + `${apiBaseUrl()}/api/runbook/snapshots/${encodeURIComponent(snapshotId)}`, + "GET", + ) } - diff --git a/web/src/app/api/runbook/snapshots/route.ts b/web/src/app/api/runbook/snapshots/route.ts index 6abd811a..1a993a3e 100644 --- a/web/src/app/api/runbook/snapshots/route.ts +++ b/web/src/app/api/runbook/snapshots/route.ts @@ -1,26 +1,7 @@ export const runtime = "nodejs" -function apiBaseUrl() { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" -} +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" export async function POST(req: Request) { - const body = await req.text() - const upstream = await fetch(`${apiBaseUrl()}/api/runbook/snapshots`, { - method: "POST", - headers: { - "Content-Type": req.headers.get("content-type") || "application/json", - Accept: "application/json", - }, - body, - }) - const text = await upstream.text() - return new Response(text, { - status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") || "application/json", - "Cache-Control": "no-cache", - }, - }) + return proxyJson(req, `${apiBaseUrl()}/api/runbook/snapshots`, "POST") } - diff --git a/web/src/app/api/sandbox/status/route.ts b/web/src/app/api/sandbox/status/route.ts index 9dc74c85..fb9f92cd 100644 --- a/web/src/app/api/sandbox/status/route.ts +++ b/web/src/app/api/sandbox/status/route.ts @@ -1,18 +1,7 @@ export const runtime = "nodejs" -function apiBaseUrl() { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" -} - -export async function GET() { - const upstream = await fetch(`${apiBaseUrl()}/api/sandbox/status`, { method: "GET" }) - const text = await upstream.text() +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" - return new Response(text, { - status: upstream.status, - headers: { - "Content-Type": upstream.headers.get("content-type") || "application/json", - "Cache-Control": "no-cache", - }, - }) +export async function GET(req: Request) { + return proxyJson(req, `${apiBaseUrl()}/api/sandbox/status`, "GET") } diff --git a/web/src/app/api/studio/chat/route.ts b/web/src/app/api/studio/chat/route.ts index 47adfd53..a7d664e2 100644 --- a/web/src/app/api/studio/chat/route.ts +++ b/web/src/app/api/studio/chat/route.ts @@ -1,28 +1,10 @@ export const runtime = "nodejs" -function apiBaseUrl() { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" -} - -import { withBackendAuth } from "../../_utils/auth-headers" +import { apiBaseUrl, proxyStream } from "@/app/api/_utils/backend-proxy" export async function POST(req: Request) { - const body = await req.text() - const upstream = await fetch(`${apiBaseUrl()}/api/studio/chat`, { - method: "POST", - headers: await withBackendAuth(req, { - "Content-Type": req.headers.get("content-type") || "application/json", - }), - body, - }) - - const headers = new Headers() - headers.set("Content-Type", upstream.headers.get("content-type") || "text/event-stream") - headers.set("Cache-Control", "no-cache") - headers.set("Connection", "keep-alive") - - return new Response(upstream.body, { - status: upstream.status, - headers, + return proxyStream(req, `${apiBaseUrl()}/api/studio/chat`, "POST", { + auth: true, + responseContentType: "text/event-stream", }) } diff --git a/web/src/app/api/studio/cwd/route.ts b/web/src/app/api/studio/cwd/route.ts index 80684cd1..10ea9da8 100644 --- a/web/src/app/api/studio/cwd/route.ts +++ b/web/src/app/api/studio/cwd/route.ts @@ -1,32 +1,17 @@ export const runtime = "nodejs" -function apiBaseUrl() { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" -} - -export async function GET() { - try { - const upstream = await fetch(`${apiBaseUrl()}/api/studio/cwd`, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }) - - const data = await upstream.json() +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" - return Response.json(data, { - status: upstream.status, - }) - } catch (error) { - // Return a sensible default if backend is unavailable - return Response.json( - { - cwd: process.env.HOME || "/tmp", - source: "fallback", - error: "Failed to get working directory from backend", - }, - { status: 200 } - ) - } +export async function GET(req: Request) { + return proxyJson(req, `${apiBaseUrl()}/api/studio/cwd`, "GET", { + onError: () => + Response.json( + { + cwd: process.env.HOME || "/tmp", + source: "fallback", + error: "Failed to get working directory from backend", + }, + { status: 200 }, + ), + }) } diff --git a/web/src/app/api/studio/status/route.ts b/web/src/app/api/studio/status/route.ts index 9c7c64de..b86820c9 100644 --- a/web/src/app/api/studio/status/route.ts +++ b/web/src/app/api/studio/status/route.ts @@ -1,31 +1,17 @@ export const runtime = "nodejs" -function apiBaseUrl() { - return process.env.PAPERBOT_API_BASE_URL || "http://127.0.0.1:8000" -} - -export async function GET() { - try { - const upstream = await fetch(`${apiBaseUrl()}/api/studio/status`, { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }) - - const data = await upstream.json() +import { apiBaseUrl, proxyJson } from "@/app/api/_utils/backend-proxy" - return Response.json(data, { - status: upstream.status, - }) - } catch (error) { - return Response.json( - { - claude_cli: false, - error: "Failed to check Claude CLI status", - fallback: "anthropic_api" - }, - { status: 500 } - ) - } +export async function GET(req: Request) { + return proxyJson(req, `${apiBaseUrl()}/api/studio/status`, "GET", { + onError: () => + Response.json( + { + claude_cli: false, + error: "Failed to check Claude CLI status", + fallback: "anthropic_api", + }, + { status: 500 }, + ), + }) }