From f4648a91aafe21b3058a09a680d7d7404b03aec7 Mon Sep 17 00:00:00 2001 From: jerry609 <1772030600@qq.com> Date: Sun, 15 Mar 2026 15:19:56 +0800 Subject: [PATCH 1/2] refactor: unify stream next proxies --- web/src/app/api/_utils/backend-proxy.test.ts | 82 ++++++++++++++++++- web/src/app/api/_utils/backend-proxy.ts | 62 +++++++++++++- web/src/app/api/gen-code/route.test.ts | 32 ++++++++ web/src/app/api/gen-code/route.ts | 26 +----- .../research/paperscool/analyze/route.test.ts | 35 ++++++++ .../api/research/paperscool/analyze/route.ts | 49 +++-------- .../research/paperscool/daily/route.test.ts | 51 ++++++++++++ .../api/research/paperscool/daily/route.ts | 68 ++++----------- .../api/research/repro/context/route.test.ts | 56 +++++++++++++ .../app/api/research/repro/context/route.ts | 24 ++---- web/src/app/api/studio/chat/route.test.ts | 32 ++++++++ web/src/app/api/studio/chat/route.ts | 26 +----- 12 files changed, 387 insertions(+), 156 deletions(-) create mode 100644 web/src/app/api/gen-code/route.test.ts create mode 100644 web/src/app/api/research/paperscool/analyze/route.test.ts create mode 100644 web/src/app/api/research/paperscool/daily/route.test.ts create mode 100644 web/src/app/api/research/repro/context/route.test.ts create mode 100644 web/src/app/api/studio/chat/route.test.ts diff --git a/web/src/app/api/_utils/backend-proxy.test.ts b/web/src/app/api/_utils/backend-proxy.test.ts index f34ca82f..d8a05271 100644 --- a/web/src/app/api/_utils/backend-proxy.test.ts +++ b/web/src/app/api/_utils/backend-proxy.test.ts @@ -10,7 +10,7 @@ vi.mock("./auth-headers", () => ({ withBackendAuth: withBackendAuthMock, })) -import { apiBaseUrl, proxyJson, proxyText } from "./backend-proxy" +import { apiBaseUrl, proxyJson, proxyStream, proxyText } from "./backend-proxy" describe("apiBaseUrl", () => { it("uses the shared backend base URL helper", () => { @@ -172,4 +172,84 @@ describe("backend proxy helpers", () => { ) expect(res.status).toBe(200) }) + + it("passes through SSE responses without buffering", async () => { + withBackendAuthMock.mockResolvedValue({ + "Content-Type": "application/json", + authorization: "Bearer stream-token", + }) + + const fetchMock = vi.fn(async () => + new Response("data: ping\n\n", { + status: 200, + headers: { "Content-Type": "text/event-stream" }, + }), + ) + global.fetch = fetchMock as typeof fetch + + const req = new Request("https://localhost/api/studio/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ prompt: "hello" }), + }) + const res = await proxyStream(req, "https://backend/api/studio/chat", "POST", { + auth: true, + responseContentType: "text/event-stream", + }) + + expect(withBackendAuthMock).toHaveBeenCalledTimes(1) + const calls = fetchMock.mock.calls as unknown[][] + const init = calls[0]?.[1] as + | (RequestInit & { dispatcher?: unknown }) + | undefined + expect(init).toBeDefined() + expect(init).toMatchObject({ + method: "POST", + headers: { + "Content-Type": "application/json", + authorization: "Bearer stream-token", + }, + body: JSON.stringify({ prompt: "hello" }), + }) + expect(init?.signal).toBeUndefined() + expect(init?.dispatcher).toBeDefined() + expect(res.headers.get("content-type")).toContain("text/event-stream") + expect(await res.text()).toContain("data: ping") + }) + + it("returns JSON when a stream route falls back to a non-stream response", async () => { + withBackendAuthMock.mockResolvedValue({ + Accept: "text/event-stream, application/json", + "Content-Type": "application/json", + authorization: "Bearer stream-token", + }) + + const fetchMock = vi.fn(async () => + new Response(JSON.stringify({ done: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ) + global.fetch = fetchMock as typeof fetch + + const req = new Request("https://localhost/api/research/paperscool/daily", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: "llm" }), + }) + const res = await proxyStream( + req, + "https://backend/api/research/paperscool/daily", + "POST", + { + accept: "text/event-stream, application/json", + auth: true, + passthroughNonStreamResponse: true, + responseContentType: "text/event-stream", + }, + ) + + expect(res.headers.get("content-type")).toContain("application/json") + await expect(res.json()).resolves.toEqual({ done: true }) + }) }) diff --git a/web/src/app/api/_utils/backend-proxy.ts b/web/src/app/api/_utils/backend-proxy.ts index 10a50ed8..cda74fb8 100644 --- a/web/src/app/api/_utils/backend-proxy.ts +++ b/web/src/app/api/_utils/backend-proxy.ts @@ -1,3 +1,5 @@ +import { Agent } from "undici" + import { backendBaseUrl, withBackendAuth } from "./auth-headers" export type ProxyMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT" @@ -22,7 +24,17 @@ type TextProxyOptions = 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() @@ -65,11 +77,58 @@ export async function proxyText( } } +export async function proxyStream( + req: Request, + upstreamUrl: string, + method: ProxyMethod, + options: StreamProxyOptions = {}, +): Promise { + const requestOptions = { + responseContentType: "text/event-stream", + timeoutMs: 0, + ...options, + } + + try { + const upstream = await fetchUpstream(req, upstreamUrl, method, requestOptions, { + dispatcher: requestOptions.dispatcher ?? SSE_DISPATCHER, + }) + const upstreamContentType = upstream.headers.get("content-type") || "" + + if ( + requestOptions.passthroughNonStreamResponse && + !upstreamContentType.includes("text/event-stream") + ) { + const text = await upstream.text() + return buildTextResponse(text, upstream, { + responseContentType: requestOptions.responseContentType ?? "application/json", + responseHeaders: undefined, + }) + } + + const headers = new Headers() + headers.set( + "Content-Type", + upstreamContentType || requestOptions.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, requestOptions.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 @@ -78,12 +137,13 @@ async function fetchUpstream( try { return await fetch(upstreamUrl, { + ...init, method, headers: await resolveHeaders(req, body, options), body, cache: options.cache, signal: controller?.signal, - }) + } as RequestInit & { dispatcher?: Agent }) } finally { if (timeout) { clearTimeout(timeout) diff --git a/web/src/app/api/gen-code/route.test.ts b/web/src/app/api/gen-code/route.test.ts new file mode 100644 index 00000000..79ccd309 --- /dev/null +++ b/web/src/app/api/gen-code/route.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from "vitest" + +const { apiBaseUrlMock, proxyStreamMock } = vi.hoisted(() => ({ + apiBaseUrlMock: vi.fn(() => "http://backend.example.com"), + proxyStreamMock: vi.fn(), +})) + +vi.mock("@/app/api/_utils/backend-proxy", () => ({ + apiBaseUrl: apiBaseUrlMock, + proxyStream: proxyStreamMock, +})) + +import { POST } from "./route" + +describe("gen code route", () => { + it("proxies code generation through the shared stream helper", async () => { + const req = new Request("http://localhost/api/gen-code", { method: "POST" }) + proxyStreamMock.mockResolvedValueOnce(new Response("data: ping\n\n")) + + await POST(req) + + expect(proxyStreamMock).toHaveBeenCalledWith( + req, + "http://backend.example.com/api/gen-code", + "POST", + { + auth: true, + responseContentType: "text/event-stream", + }, + ) + }) +}) diff --git a/web/src/app/api/gen-code/route.ts b/web/src/app/api/gen-code/route.ts index c5f78144..740dc3dc 100644 --- a/web/src/app/api/gen-code/route.ts +++ b/web/src/app/api/gen-code/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/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", { + auth: true, + responseContentType: "text/event-stream", }) } diff --git a/web/src/app/api/research/paperscool/analyze/route.test.ts b/web/src/app/api/research/paperscool/analyze/route.test.ts new file mode 100644 index 00000000..21024c4b --- /dev/null +++ b/web/src/app/api/research/paperscool/analyze/route.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it, vi } from "vitest" + +const { apiBaseUrlMock, proxyStreamMock } = vi.hoisted(() => ({ + apiBaseUrlMock: vi.fn(() => "http://backend.example.com"), + proxyStreamMock: vi.fn(), +})) + +vi.mock("@/app/api/_utils/backend-proxy", () => ({ + apiBaseUrl: apiBaseUrlMock, + proxyStream: proxyStreamMock, +})) + +import { POST } from "./route" + +describe("paperscool analyze route", () => { + it("proxies analyze requests through the shared stream helper", async () => { + const req = new Request("http://localhost/api/research/paperscool/analyze", { + method: "POST", + }) + proxyStreamMock.mockResolvedValueOnce(new Response("data: ping\n\n")) + + await POST(req) + + expect(proxyStreamMock).toHaveBeenCalledWith( + req, + "http://backend.example.com/api/research/paperscool/analyze", + "POST", + expect.objectContaining({ + accept: "text/event-stream", + responseContentType: "text/event-stream", + onError: expect.any(Function), + }), + ) + }) +}) diff --git a/web/src/app/api/research/paperscool/analyze/route.ts b/web/src/app/api/research/paperscool/analyze/route.ts index 32c83ad2..b9910b92 100644 --- a/web/src/app/api/research/paperscool/analyze/route.ts +++ b/web/src/app/api/research/paperscool/analyze/route.ts @@ -1,43 +1,18 @@ 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", + onError: ({ error }) => + Response.json( + { + detail: "Upstream API unreachable", + error: error instanceof Error ? error.message : String(error), + }, + { status: 502 }, + ), }) } diff --git a/web/src/app/api/research/paperscool/daily/route.test.ts b/web/src/app/api/research/paperscool/daily/route.test.ts new file mode 100644 index 00000000..1d027203 --- /dev/null +++ b/web/src/app/api/research/paperscool/daily/route.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it, vi } from "vitest" + +const { apiBaseUrlMock, proxyStreamMock } = vi.hoisted(() => ({ + apiBaseUrlMock: vi.fn(() => "http://backend.example.com"), + proxyStreamMock: vi.fn(), +})) + +vi.mock("@/app/api/_utils/backend-proxy", () => ({ + apiBaseUrl: apiBaseUrlMock, + proxyStream: proxyStreamMock, +})) + +import { POST } from "./route" + +describe("paperscool daily route", () => { + it("proxies daily generation with stream/json passthrough options", async () => { + const req = new Request("http://localhost/api/research/paperscool/daily", { + method: "POST", + }) + proxyStreamMock.mockResolvedValueOnce(new Response("data: ping\n\n")) + + await POST(req) + + expect(proxyStreamMock).toHaveBeenCalledWith( + req, + "http://backend.example.com/api/research/paperscool/daily", + "POST", + expect.objectContaining({ + accept: "text/event-stream, application/json", + auth: true, + passthroughNonStreamResponse: true, + responseContentType: "text/event-stream", + onError: expect.any(Function), + }), + ) + + const options = vi.mocked(proxyStreamMock).mock.calls[0][3] + const fallback = options?.onError?.({ + error: new Error("offline"), + isTimeout: false, + upstreamUrl: "http://backend.example.com/api/research/paperscool/daily", + }) + + expect(fallback).toBeInstanceOf(Response) + expect(fallback?.status).toBe(502) + await expect(fallback?.json()).resolves.toEqual({ + detail: "Upstream API unreachable", + error: "offline", + }) + }) +}) diff --git a/web/src/app/api/research/paperscool/daily/route.ts b/web/src/app/api/research/paperscool/daily/route.ts index 3cce1e0b..42241bcb 100644 --- a/web/src/app/api/research/paperscool/daily/route.ts +++ b/web/src/app/api/research/paperscool/daily/route.ts @@ -1,60 +1,20 @@ 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", + onError: ({ error }) => + Response.json( + { + detail: "Upstream API unreachable", + error: error instanceof Error ? error.message : String(error), + }, + { status: 502 }, + ), }) } diff --git a/web/src/app/api/research/repro/context/route.test.ts b/web/src/app/api/research/repro/context/route.test.ts new file mode 100644 index 00000000..9fe00aa8 --- /dev/null +++ b/web/src/app/api/research/repro/context/route.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it, vi } from "vitest" + +const { researchApiBaseUrlMock, proxyJsonMock } = vi.hoisted(() => ({ + researchApiBaseUrlMock: vi.fn(() => "http://backend.example.com"), + proxyJsonMock: vi.fn(), +})) + +const { proxyStreamMock } = vi.hoisted(() => ({ + proxyStreamMock: vi.fn(), +})) + +vi.mock("../../_base", () => ({ + apiBaseUrl: researchApiBaseUrlMock, + proxyJson: proxyJsonMock, +})) + +vi.mock("@/app/api/_utils/backend-proxy", () => ({ + proxyStream: proxyStreamMock, +})) + +import { GET, POST } from "./route" + +describe("research repro context route", () => { + it("keeps the GET proxy on the research base helper", async () => { + const req = new Request("http://localhost/api/research/repro/context?paper_id=123") + proxyJsonMock.mockResolvedValueOnce(Response.json({ ok: true })) + + await GET(req) + + expect(proxyJsonMock).toHaveBeenCalledWith( + req, + "http://backend.example.com/api/research/repro/context?paper_id=123", + "GET", + ) + }) + + it("proxies context generation through the shared stream helper", async () => { + const req = new Request("http://localhost/api/research/repro/context", { + method: "POST", + }) + proxyStreamMock.mockResolvedValueOnce(new Response("data: ping\n\n")) + + await POST(req) + + expect(proxyStreamMock).toHaveBeenCalledWith( + req, + "http://backend.example.com/api/research/repro/context/generate", + "POST", + { + accept: "text/event-stream", + auth: true, + responseContentType: "text/event-stream", + }, + ) + }) +}) 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/studio/chat/route.test.ts b/web/src/app/api/studio/chat/route.test.ts new file mode 100644 index 00000000..d2b00306 --- /dev/null +++ b/web/src/app/api/studio/chat/route.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it, vi } from "vitest" + +const { apiBaseUrlMock, proxyStreamMock } = vi.hoisted(() => ({ + apiBaseUrlMock: vi.fn(() => "http://backend.example.com"), + proxyStreamMock: vi.fn(), +})) + +vi.mock("@/app/api/_utils/backend-proxy", () => ({ + apiBaseUrl: apiBaseUrlMock, + proxyStream: proxyStreamMock, +})) + +import { POST } from "./route" + +describe("studio chat route", () => { + it("proxies studio chat through the shared stream helper", async () => { + const req = new Request("http://localhost/api/studio/chat", { method: "POST" }) + proxyStreamMock.mockResolvedValueOnce(new Response("data: ping\n\n")) + + await POST(req) + + expect(proxyStreamMock).toHaveBeenCalledWith( + req, + "http://backend.example.com/api/studio/chat", + "POST", + { + auth: true, + responseContentType: "text/event-stream", + }, + ) + }) +}) 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", }) } From e1896b4252cb5718f3da2c2c43f20bad1bfc12eb Mon Sep 17 00:00:00 2001 From: jerry609 <1772030600@qq.com> Date: Sun, 15 Mar 2026 15:38:30 +0800 Subject: [PATCH 2/2] test: reduce stream proxy duplication --- web/src/app/api/_utils/backend-proxy.test.ts | 5 +- web/src/app/api/_utils/backend-proxy.ts | 2 +- .../api/_utils/stream-route-contracts.test.ts | 118 ++++++++++++++++++ web/src/app/api/gen-code/route.test.ts | 32 ----- .../research/paperscool/analyze/route.test.ts | 35 ------ .../research/paperscool/daily/route.test.ts | 51 -------- web/src/app/api/studio/chat/route.test.ts | 32 ----- 7 files changed, 120 insertions(+), 155 deletions(-) create mode 100644 web/src/app/api/_utils/stream-route-contracts.test.ts delete mode 100644 web/src/app/api/gen-code/route.test.ts delete mode 100644 web/src/app/api/research/paperscool/analyze/route.test.ts delete mode 100644 web/src/app/api/research/paperscool/daily/route.test.ts delete mode 100644 web/src/app/api/studio/chat/route.test.ts diff --git a/web/src/app/api/_utils/backend-proxy.test.ts b/web/src/app/api/_utils/backend-proxy.test.ts index d8a05271..fc695081 100644 --- a/web/src/app/api/_utils/backend-proxy.test.ts +++ b/web/src/app/api/_utils/backend-proxy.test.ts @@ -225,10 +225,7 @@ describe("backend proxy helpers", () => { }) const fetchMock = vi.fn(async () => - new Response(JSON.stringify({ done: true }), { - status: 200, - headers: { "Content-Type": "application/json" }, - }), + new Response(new TextEncoder().encode(JSON.stringify({ done: true })), { status: 200 }), ) global.fetch = fetchMock as typeof fetch diff --git a/web/src/app/api/_utils/backend-proxy.ts b/web/src/app/api/_utils/backend-proxy.ts index cda74fb8..4e766ff6 100644 --- a/web/src/app/api/_utils/backend-proxy.ts +++ b/web/src/app/api/_utils/backend-proxy.ts @@ -101,7 +101,7 @@ export async function proxyStream( ) { const text = await upstream.text() return buildTextResponse(text, upstream, { - responseContentType: requestOptions.responseContentType ?? "application/json", + responseContentType: "application/json", responseHeaders: undefined, }) } diff --git a/web/src/app/api/_utils/stream-route-contracts.test.ts b/web/src/app/api/_utils/stream-route-contracts.test.ts new file mode 100644 index 00000000..19ff862b --- /dev/null +++ b/web/src/app/api/_utils/stream-route-contracts.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +const { apiBaseUrlMock, proxyStreamMock } = vi.hoisted(() => ({ + apiBaseUrlMock: vi.fn(() => "http://backend.example.com"), + proxyStreamMock: vi.fn(), +})) + +vi.mock("@/app/api/_utils/backend-proxy", () => ({ + apiBaseUrl: apiBaseUrlMock, + proxyStream: proxyStreamMock, +})) + +import { POST as genCodePost } from "@/app/api/gen-code/route" +import { POST as paperscoolAnalyzePost } from "@/app/api/research/paperscool/analyze/route" +import { POST as paperscoolDailyPost } from "@/app/api/research/paperscool/daily/route" +import { POST as studioChatPost } from "@/app/api/studio/chat/route" + +type StreamRouteCase = { + expectedOptions: Record + handler: (req: Request) => Promise + name: string + path: string +} + +const streamRouteCases: StreamRouteCase[] = [ + { + name: "studio chat", + handler: studioChatPost, + path: "/api/studio/chat", + expectedOptions: { + auth: true, + responseContentType: "text/event-stream", + }, + }, + { + name: "gen code", + handler: genCodePost, + path: "/api/gen-code", + expectedOptions: { + auth: true, + responseContentType: "text/event-stream", + }, + }, + { + name: "paperscool analyze", + handler: paperscoolAnalyzePost, + path: "/api/research/paperscool/analyze", + expectedOptions: { + accept: "text/event-stream", + responseContentType: "text/event-stream", + onError: expect.any(Function), + }, + }, +] + +describe("stream route contracts", () => { + beforeEach(() => { + vi.resetAllMocks() + apiBaseUrlMock.mockReturnValue("http://backend.example.com") + }) + + it.each(streamRouteCases)("proxies $name through the shared stream helper", async ({ + expectedOptions, + handler, + path, + }) => { + const req = new Request(`http://localhost${path}`, { method: "POST" }) + proxyStreamMock.mockResolvedValueOnce(new Response("data: ping\n\n")) + + await handler(req) + + expect(proxyStreamMock).toHaveBeenCalledWith( + req, + `http://backend.example.com${path}`, + "POST", + expectedOptions, + ) + }) + + it("preserves the daily stream/json passthrough fallback contract", async () => { + const req = new Request("http://localhost/api/research/paperscool/daily", { + method: "POST", + }) + proxyStreamMock.mockResolvedValueOnce(new Response("data: ping\n\n")) + + await paperscoolDailyPost(req) + + expect(proxyStreamMock).toHaveBeenCalledWith( + req, + "http://backend.example.com/api/research/paperscool/daily", + "POST", + expect.objectContaining({ + accept: "text/event-stream, application/json", + auth: true, + passthroughNonStreamResponse: true, + responseContentType: "text/event-stream", + onError: expect.any(Function), + }), + ) + + const calls = vi.mocked(proxyStreamMock).mock.calls as unknown[][] + const options = calls[0]?.[3] as + | { onError?: (context: { error: unknown; isTimeout: boolean; upstreamUrl: string }) => Response } + | undefined + const fallback = options?.onError?.({ + error: new Error("offline"), + isTimeout: false, + upstreamUrl: "http://backend.example.com/api/research/paperscool/daily", + }) + + expect(fallback).toBeInstanceOf(Response) + expect(fallback?.status).toBe(502) + await expect(fallback?.json()).resolves.toEqual({ + detail: "Upstream API unreachable", + error: "offline", + }) + }) +}) diff --git a/web/src/app/api/gen-code/route.test.ts b/web/src/app/api/gen-code/route.test.ts deleted file mode 100644 index 79ccd309..00000000 --- a/web/src/app/api/gen-code/route.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it, vi } from "vitest" - -const { apiBaseUrlMock, proxyStreamMock } = vi.hoisted(() => ({ - apiBaseUrlMock: vi.fn(() => "http://backend.example.com"), - proxyStreamMock: vi.fn(), -})) - -vi.mock("@/app/api/_utils/backend-proxy", () => ({ - apiBaseUrl: apiBaseUrlMock, - proxyStream: proxyStreamMock, -})) - -import { POST } from "./route" - -describe("gen code route", () => { - it("proxies code generation through the shared stream helper", async () => { - const req = new Request("http://localhost/api/gen-code", { method: "POST" }) - proxyStreamMock.mockResolvedValueOnce(new Response("data: ping\n\n")) - - await POST(req) - - expect(proxyStreamMock).toHaveBeenCalledWith( - req, - "http://backend.example.com/api/gen-code", - "POST", - { - auth: true, - responseContentType: "text/event-stream", - }, - ) - }) -}) diff --git a/web/src/app/api/research/paperscool/analyze/route.test.ts b/web/src/app/api/research/paperscool/analyze/route.test.ts deleted file mode 100644 index 21024c4b..00000000 --- a/web/src/app/api/research/paperscool/analyze/route.test.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it, vi } from "vitest" - -const { apiBaseUrlMock, proxyStreamMock } = vi.hoisted(() => ({ - apiBaseUrlMock: vi.fn(() => "http://backend.example.com"), - proxyStreamMock: vi.fn(), -})) - -vi.mock("@/app/api/_utils/backend-proxy", () => ({ - apiBaseUrl: apiBaseUrlMock, - proxyStream: proxyStreamMock, -})) - -import { POST } from "./route" - -describe("paperscool analyze route", () => { - it("proxies analyze requests through the shared stream helper", async () => { - const req = new Request("http://localhost/api/research/paperscool/analyze", { - method: "POST", - }) - proxyStreamMock.mockResolvedValueOnce(new Response("data: ping\n\n")) - - await POST(req) - - expect(proxyStreamMock).toHaveBeenCalledWith( - req, - "http://backend.example.com/api/research/paperscool/analyze", - "POST", - expect.objectContaining({ - accept: "text/event-stream", - responseContentType: "text/event-stream", - onError: expect.any(Function), - }), - ) - }) -}) diff --git a/web/src/app/api/research/paperscool/daily/route.test.ts b/web/src/app/api/research/paperscool/daily/route.test.ts deleted file mode 100644 index 1d027203..00000000 --- a/web/src/app/api/research/paperscool/daily/route.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it, vi } from "vitest" - -const { apiBaseUrlMock, proxyStreamMock } = vi.hoisted(() => ({ - apiBaseUrlMock: vi.fn(() => "http://backend.example.com"), - proxyStreamMock: vi.fn(), -})) - -vi.mock("@/app/api/_utils/backend-proxy", () => ({ - apiBaseUrl: apiBaseUrlMock, - proxyStream: proxyStreamMock, -})) - -import { POST } from "./route" - -describe("paperscool daily route", () => { - it("proxies daily generation with stream/json passthrough options", async () => { - const req = new Request("http://localhost/api/research/paperscool/daily", { - method: "POST", - }) - proxyStreamMock.mockResolvedValueOnce(new Response("data: ping\n\n")) - - await POST(req) - - expect(proxyStreamMock).toHaveBeenCalledWith( - req, - "http://backend.example.com/api/research/paperscool/daily", - "POST", - expect.objectContaining({ - accept: "text/event-stream, application/json", - auth: true, - passthroughNonStreamResponse: true, - responseContentType: "text/event-stream", - onError: expect.any(Function), - }), - ) - - const options = vi.mocked(proxyStreamMock).mock.calls[0][3] - const fallback = options?.onError?.({ - error: new Error("offline"), - isTimeout: false, - upstreamUrl: "http://backend.example.com/api/research/paperscool/daily", - }) - - expect(fallback).toBeInstanceOf(Response) - expect(fallback?.status).toBe(502) - await expect(fallback?.json()).resolves.toEqual({ - detail: "Upstream API unreachable", - error: "offline", - }) - }) -}) diff --git a/web/src/app/api/studio/chat/route.test.ts b/web/src/app/api/studio/chat/route.test.ts deleted file mode 100644 index d2b00306..00000000 --- a/web/src/app/api/studio/chat/route.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { describe, expect, it, vi } from "vitest" - -const { apiBaseUrlMock, proxyStreamMock } = vi.hoisted(() => ({ - apiBaseUrlMock: vi.fn(() => "http://backend.example.com"), - proxyStreamMock: vi.fn(), -})) - -vi.mock("@/app/api/_utils/backend-proxy", () => ({ - apiBaseUrl: apiBaseUrlMock, - proxyStream: proxyStreamMock, -})) - -import { POST } from "./route" - -describe("studio chat route", () => { - it("proxies studio chat through the shared stream helper", async () => { - const req = new Request("http://localhost/api/studio/chat", { method: "POST" }) - proxyStreamMock.mockResolvedValueOnce(new Response("data: ping\n\n")) - - await POST(req) - - expect(proxyStreamMock).toHaveBeenCalledWith( - req, - "http://backend.example.com/api/studio/chat", - "POST", - { - auth: true, - responseContentType: "text/event-stream", - }, - ) - }) -})