diff --git a/app/src/components/EditableRecommendation.test.tsx b/app/src/components/EditableRecommendation.test.tsx index 6070889..8a5d47e 100644 --- a/app/src/components/EditableRecommendation.test.tsx +++ b/app/src/components/EditableRecommendation.test.tsx @@ -102,3 +102,77 @@ describe("EditableRecommendation", () => { }); }); }); + +describe("EditableRecommendation inline failure message", () => { + const defaultProps = { + label: "Meta Title", + initialValue: "My SEO Title", + onRegenerate: vi.fn().mockResolvedValue("Regenerated title"), + onToast: vi.fn(), + }; + + it("shows no failure message initially", () => { + render(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("keeps the failure visible in the box after a failed regenerate", async () => { + const user = userEvent.setup(); + const onRegenerate = vi.fn().mockRejectedValue(new Error("API error")); + render(); + + await user.click(screen.getByTitle("Regenerate")); + + expect(screen.getByRole("alert")).toHaveTextContent("Failed to regenerate"); + // The box keeps whatever text it had; the message is what signals the failure. + expect(screen.getByRole("textbox")).toHaveValue("My SEO Title"); + }); + + it("surfaces the AI service's own message when it explains the failure", async () => { + const user = userEvent.setup(); + const quota = Object.assign(new Error("AI quota reached."), { name: "AiProxyError" }); + const onRegenerate = vi.fn().mockRejectedValue(quota); + render(); + + await user.click(screen.getByTitle("Regenerate")); + + expect(screen.getByRole("alert")).toHaveTextContent("AI quota reached."); + }); + + it("clears the failure once a later regenerate succeeds", async () => { + const user = userEvent.setup(); + const onRegenerate = vi + .fn() + .mockRejectedValueOnce(new Error("API error")) + .mockResolvedValueOnce("Regenerated title"); + render(); + + await user.click(screen.getByTitle("Regenerate")); + expect(screen.getByRole("alert")).toBeInTheDocument(); + + await user.click(screen.getByTitle("Regenerate")); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.getByRole("textbox")).toHaveValue("Regenerated title"); + }); + + it("clears the failure when a new suggestion arrives from the parent", async () => { + const user = userEvent.setup(); + const onRegenerate = vi.fn().mockRejectedValue(new Error("API error")); + const { rerender } = render( + , + ); + + await user.click(screen.getByTitle("Regenerate")); + expect(screen.getByRole("alert")).toBeInTheDocument(); + + rerender( + , + ); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.getByRole("textbox")).toHaveValue("Fresh suggestion"); + }); +}); diff --git a/app/src/components/EditableRecommendation.tsx b/app/src/components/EditableRecommendation.tsx index 7c8d17f..262739a 100644 --- a/app/src/components/EditableRecommendation.tsx +++ b/app/src/components/EditableRecommendation.tsx @@ -21,11 +21,16 @@ export function EditableRecommendation({ className, }: EditableRecommendationProps) { const [text, setText] = useState(initialValue); + // Last generation failure, shown inside the box until the next attempt or a + // new suggestion. The toast alone disappears within seconds and leaves an + // empty box that looks like nothing happened (#67). + const [error, setError] = useState(null); const textareaRef = useRef(null); // Sync state when initialValue changes (e.g., when AI suggestions complete) useEffect(() => { setText(initialValue); + setError(null); }, [initialValue]); // Auto-resize textarea to fit content @@ -52,12 +57,15 @@ export function EditableRecommendation({ const handleRegenerate = useCallback(async () => { setLoading(true); + setError(null); try { const newText = await onRegenerate(); setText(newText); onToast("Recommendation regenerated"); } catch (err) { - onToast(aiErrorMessage(err, "Failed to regenerate")); + const message = aiErrorMessage(err, "Failed to regenerate"); + onToast(message); + setError(message); } finally { setLoading(false); } @@ -108,6 +116,11 @@ export function EditableRecommendation({ rows={1} className="w-full rounded-input bg-surface px-3 py-2 text-body-16 text-ink placeholder:text-faint outline-none focus:ring-1 focus:ring-brand transition-shadow resize-none overflow-hidden" /> + {error && ( +

+ {error} +

+ )} {aiDisabled && (

Activate Optia Pro or add your own Anthropic key in options to use AI suggestions. diff --git a/app/src/components/H2SelectionList.test.tsx b/app/src/components/H2SelectionList.test.tsx index 1344cb2..6d68523 100644 --- a/app/src/components/H2SelectionList.test.tsx +++ b/app/src/components/H2SelectionList.test.tsx @@ -145,3 +145,100 @@ describe("H2SelectionList", () => { }); }); }); + +describe("H2SelectionList inline failure messages", () => { + const items = [ + { index: 0, text: "Introduction", suggestion: "" }, + { index: 1, text: "Features", suggestion: "Top Features" }, + ]; + + const defaultProps = { + items, + onRegenerateOne: vi.fn().mockResolvedValue("Better heading"), + onRegenerateAll: vi.fn().mockResolvedValue(["New Intro", "New Features"]), + onToast: vi.fn(), + }; + + it("shows no failure messages initially", () => { + render(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("marks only the failed item after a per-item regenerate fails", async () => { + const user = userEvent.setup(); + const onRegenerateOne = vi.fn().mockRejectedValue(new Error("fail")); + render(); + + await user.click(screen.getAllByTitle("Regenerate")[0]); + + const alerts = screen.getAllByRole("alert"); + expect(alerts).toHaveLength(1); + expect(alerts[0]).toHaveTextContent("Failed to regenerate"); + }); + + it("surfaces the AI service's own message on a per-item failure", async () => { + const user = userEvent.setup(); + const quota = Object.assign(new Error("AI quota reached."), { name: "AiProxyError" }); + const onRegenerateOne = vi.fn().mockRejectedValue(quota); + render(); + + await user.click(screen.getAllByTitle("Regenerate")[1]); + + expect(screen.getByRole("alert")).toHaveTextContent("AI quota reached."); + }); + + it("clears an item's failure once it regenerates successfully", async () => { + const user = userEvent.setup(); + const onRegenerateOne = vi + .fn() + .mockRejectedValueOnce(new Error("fail")) + .mockResolvedValueOnce("Better heading"); + render(); + + await user.click(screen.getAllByTitle("Regenerate")[0]); + expect(screen.getByRole("alert")).toBeInTheDocument(); + + await user.click(screen.getAllByTitle("Regenerate")[0]); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.getAllByRole("textbox")[0]).toHaveValue("Better heading"); + }); + + it("marks the slots that Generate All could not fill", async () => { + const user = userEvent.setup(); + const onRegenerateAll = vi.fn().mockResolvedValue([null, "New Features"]); + render(); + + await user.click(screen.getByText("Generate All")); + + const alerts = screen.getAllByRole("alert"); + expect(alerts).toHaveLength(1); + expect(alerts[0]).toHaveTextContent("Failed to generate"); + expect(screen.getByDisplayValue("New Features")).toBeInTheDocument(); + }); + + it("marks every item when Generate All fails outright", async () => { + const user = userEvent.setup(); + const quota = Object.assign(new Error("AI quota reached."), { name: "AiProxyError" }); + const onRegenerateAll = vi.fn().mockRejectedValue(quota); + render(); + + await user.click(screen.getByText("Generate All")); + + const alerts = screen.getAllByRole("alert"); + expect(alerts).toHaveLength(2); + for (const alert of alerts) expect(alert).toHaveTextContent("AI quota reached."); + }); + + it("clears earlier failures when Generate All later fills every slot", async () => { + const user = userEvent.setup(); + const onRegenerateOne = vi.fn().mockRejectedValue(new Error("fail")); + render(); + + await user.click(screen.getAllByTitle("Regenerate")[0]); + expect(screen.getByRole("alert")).toBeInTheDocument(); + + await user.click(screen.getByText("Generate All")); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + expect(screen.getByDisplayValue("New Intro")).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/H2SelectionList.tsx b/app/src/components/H2SelectionList.tsx index 7b3938c..81d7401 100644 --- a/app/src/components/H2SelectionList.tsx +++ b/app/src/components/H2SelectionList.tsx @@ -19,6 +19,13 @@ interface H2SelectionListProps { className?: string; } +function omitIndex(errors: Record, index: number): Record { + if (!(index in errors)) return errors; + const next = { ...errors }; + delete next[index]; + return next; +} + export function H2SelectionList({ items, onRegenerateOne, @@ -69,16 +76,22 @@ export function H2SelectionList({ const [loadingItems, setLoadingItems] = useState>(new Set()); const [copiedItems, setCopiedItems] = useState>(new Set()); const [loadingAll, setLoadingAll] = useState(false); + // Per-item generation failures, shown inside each item's card until that + // item is retried or a later Generate All fills it (#67). + const [errors, setErrors] = useState>({}); const handleRegenerateOne = useCallback( async (index: number, h2Text: string) => { setLoadingItems((prev) => new Set(prev).add(index)); + setErrors((prev) => omitIndex(prev, index)); try { const result = await onRegenerateOne(index, h2Text); setSuggestions((prev) => ({ ...prev, [index]: result })); onToast("H2 suggestion regenerated"); } catch (err) { - onToast(aiErrorMessage(err, "Failed to regenerate")); + const message = aiErrorMessage(err, "Failed to regenerate"); + onToast(message); + setErrors((prev) => ({ ...prev, [index]: message })); } finally { setLoadingItems((prev) => { const next = new Set(prev); @@ -92,10 +105,11 @@ export function H2SelectionList({ const handleRegenerateAll = useCallback(async () => { setLoadingAll(true); + setErrors({}); try { const results = await onRegenerateAll(); // Keep every suggestion that succeeded; failed slots stay empty for - // per-item retry (their quota was refunded server-side). + // per-item retry (their quota was refunded server-side) and say so. setSuggestions((prev) => { const next = { ...prev }; items.forEach((item, i) => { @@ -104,6 +118,13 @@ export function H2SelectionList({ }); return next; }); + setErrors(() => { + const next: Record = {}; + items.forEach((item, i) => { + if (results[i] == null) next[item.index] = "Failed to generate"; + }); + return next; + }); const failed = items.filter((_, i) => results[i] == null).length; onToast( failed === 0 @@ -111,7 +132,10 @@ export function H2SelectionList({ : `Generated ${items.length - failed} of ${items.length} — retry the rest individually`, ); } catch (err) { - onToast(aiErrorMessage(err, "Failed to generate suggestions")); + // onRegenerateAll rejects only when every generation failed. + const message = aiErrorMessage(err, "Failed to generate suggestions"); + onToast(message); + setErrors(Object.fromEntries(items.map((item) => [item.index, message]))); } finally { setLoadingAll(false); } @@ -215,6 +239,11 @@ export function H2SelectionList({ + {errors[item.index] && ( +

+ {errors[item.index]} +

+ )} ); })} diff --git a/app/src/lib/entitlement-keys.test.ts b/app/src/lib/entitlement-keys.test.ts new file mode 100644 index 0000000..03d792c --- /dev/null +++ b/app/src/lib/entitlement-keys.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { + BACKEND_BASE_URL, + DEV_BACKEND_PROXY_PATH, + resolveBackendBaseUrl, +} from "@/lib/entitlement-keys"; + +const STAGING = "https://optia-backend-staging.paul-130.workers.dev"; +const PRODUCTION = "https://api.optia-api.com"; + +// window.location shapes for the three places the app's modules load. +const harnessPage = { protocol: "http:", origin: "http://localhost:5173" }; +const extensionPage = { + protocol: "chrome-extension:", + origin: "chrome-extension://gnlidlpidaoalbbmekofjednjkhhmehn", +}; + +describe("resolveBackendBaseUrl", () => { + it("routes the dev preview harness through the vite /api/backend proxy (#67)", () => { + expect(resolveBackendBaseUrl("development", harnessPage)).toBe( + `http://localhost:5173${DEV_BACKEND_PROXY_PATH}`, + ); + }); + + it("keeps the proxy on the page origin when the harness is served elsewhere", () => { + expect( + resolveBackendBaseUrl("development", { protocol: "http:", origin: "http://192.168.1.20:4173" }), + ).toBe("http://192.168.1.20:4173/api/backend"); + }); + + it("sends the unpacked dev extension (chrome-extension:// pages) to staging", () => { + expect(resolveBackendBaseUrl("development", extensionPage)).toBe(STAGING); + }); + + it("sends the service worker (no document location) to staging in development", () => { + expect(resolveBackendBaseUrl("development", undefined)).toBe(STAGING); + }); + + it("sends production builds to the production API regardless of location", () => { + expect(resolveBackendBaseUrl("production", harnessPage)).toBe(PRODUCTION); + expect(resolveBackendBaseUrl("production", extensionPage)).toBe(PRODUCTION); + expect(resolveBackendBaseUrl("production", undefined)).toBe(PRODUCTION); + }); + + it("sends every other mode (test, staging) to staging even on localhost", () => { + expect(resolveBackendBaseUrl("test", harnessPage)).toBe(STAGING); + expect(resolveBackendBaseUrl("staging", harnessPage)).toBe(STAGING); + }); +}); + +describe("BACKEND_BASE_URL under vitest", () => { + it("is the staging URL, so unit tests never resolve to the harness proxy", () => { + expect(BACKEND_BASE_URL).toBe(STAGING); + }); +}); diff --git a/app/src/lib/entitlement-keys.ts b/app/src/lib/entitlement-keys.ts index 6c65408..d6c64ba 100644 --- a/app/src/lib/entitlement-keys.ts +++ b/app/src/lib/entitlement-keys.ts @@ -32,9 +32,39 @@ const PRODUCTION_JWK: EntitlementJwk = { const isProduction = import.meta.env.MODE === "production"; -export const BACKEND_BASE_URL: string = isProduction - ? "https://api.optia-api.com" - : "https://optia-backend-staging.paul-130.workers.dev"; +const PRODUCTION_BACKEND_URL = "https://api.optia-api.com"; +const STAGING_BACKEND_URL = "https://optia-backend-staging.paul-130.workers.dev"; + +/** Mount path of the vite dev server's backend proxy (see vite.config.dev.ts). */ +export const DEV_BACKEND_PROXY_PATH = "/api/backend"; + +/** + * Picks the backend origin for a build. Production builds use the production + * API and everything else uses staging, with one exception: the dev preview + * harness (dev.html served by vite over http://localhost) cannot call staging + * directly, because staging's CORS allowlist admits only the extension origin + * and the staging site (#67). The harness talks to staging through the dev + * server's /api/backend proxy instead, the same way BYOK calls go through + * /api/anthropic. `location` is the page's window.location, or undefined where + * there is no document (the service worker). + */ +export function resolveBackendBaseUrl( + mode: string, + location: Pick | undefined, +): string { + if (mode === "production") return PRODUCTION_BACKEND_URL; + // Only the harness is served over http(s) in development; the unpacked dev + // extension's pages are chrome-extension:// and keep going to staging. + if (mode === "development" && location && /^https?:$/.test(location.protocol)) { + return `${location.origin}${DEV_BACKEND_PROXY_PATH}`; + } + return STAGING_BACKEND_URL; +} + +export const BACKEND_BASE_URL: string = resolveBackendBaseUrl( + import.meta.env.MODE, + typeof window === "undefined" ? undefined : window.location, +); // Array to accommodate rotation overlap (multi-entry JWKS) later export const ENTITLEMENT_JWKS: EntitlementJwk[] = isProduction diff --git a/app/src/test/dev-backend-proxy.test.ts b/app/src/test/dev-backend-proxy.test.ts new file mode 100644 index 0000000..a96006a --- /dev/null +++ b/app/src/test/dev-backend-proxy.test.ts @@ -0,0 +1,165 @@ +// @vitest-environment node +import { describe, it, expect, vi, afterEach } from "vitest"; +import { Readable } from "node:stream"; +import type { ServerResponse } from "node:http"; +import type { Connect, ViteDevServer } from "vite"; +import { backendProxy, DEV_BACKEND_TARGET } from "../../vite.config.dev"; + +// Drives the /api/backend middleware the dev preview harness relies on (#67) +// with hand-built request/response objects and a stubbed global fetch, so the +// proxy's forwarding rules are pinned without starting a vite server. + +type Handler = Connect.NextHandleFunction; + +async function mountBackendProxy(): Promise { + const plugin = backendProxy(); + const mounted = new Map(); + const server = { + middlewares: { + use: (path: string, handler: Handler) => { + mounted.set(path, handler); + }, + }, + } as unknown as ViteDevServer; + + const hook = plugin.configureServer; + if (!hook) throw new Error("backendProxy has no configureServer hook"); + await (typeof hook === "function" ? hook : hook.handler)(server); + + const handler = mounted.get("/api/backend"); + if (!handler) throw new Error("backendProxy did not mount /api/backend"); + return handler; +} + +interface FakeRequestInit { + method: string; + /** The path as connect hands it to a mounted middleware: mount prefix already stripped. */ + url: string; + headers?: Record; + body?: string; +} + +function fakeRequest({ method, url, headers = {}, body }: FakeRequestInit) { + const stream = Readable.from(body === undefined ? [] : [Buffer.from(body)]); + return Object.assign(stream, { method, url, headers }) as unknown as Connect.IncomingMessage; +} + +function fakeResponse() { + const res = { writeHead: vi.fn(), end: vi.fn() }; + res.writeHead.mockReturnValue(res); + return res as unknown as ServerResponse & typeof res; +} + +function upstream(status: number, body: string, contentType = "application/json") { + return new Response(body, { status, headers: { "content-type": contentType } }); +} + +async function proxy(req: Connect.IncomingMessage) { + const handler = await mountBackendProxy(); + const res = fakeResponse(); + await handler(req, res, vi.fn()); + return res; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("backendProxy (dev harness → staging backend)", () => { + it("forwards a GET to the staging backend and relays status, content type and body", async () => { + const fetchMock = vi + .fn() + .mockResolvedValue(upstream(200, '{"models":["claude-x"]}', "application/json; charset=utf-8")); + vi.stubGlobal("fetch", fetchMock); + + const res = await proxy(fakeRequest({ method: "GET", url: "/ai/models?installId=abc" })); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith(`${DEV_BACKEND_TARGET}/ai/models?installId=abc`, { + method: "GET", + headers: {}, + body: undefined, + }); + expect(res.writeHead).toHaveBeenCalledWith(200, { + "Content-Type": "application/json; charset=utf-8", + "Access-Control-Allow-Origin": "*", + }); + expect(res.end).toHaveBeenCalledWith('{"models":["claude-x"]}'); + }); + + it("forwards a POST body with only the headers the backend accepts", async () => { + const fetchMock = vi.fn().mockResolvedValue(upstream(200, '{"recommendation":"ok"}')); + vi.stubGlobal("fetch", fetchMock); + const body = JSON.stringify({ checkId: "title-keyword", keyword: "seo", installId: "install-1" }); + + await proxy( + fakeRequest({ + method: "POST", + url: "/ai/generate", + body, + headers: { + "content-type": "application/json", + "x-optia-entitlement": "tok-123", + "x-optia-install-id": "install-1", + authorization: "Bearer abc", + // Browser-supplied headers that must not reach staging. + origin: "http://localhost:5173", + host: "localhost:5173", + referer: "http://localhost:5173/dev.html", + cookie: "session=secret", + "sec-fetch-mode": "cors", + }, + }), + ); + + expect(fetchMock).toHaveBeenCalledWith(`${DEV_BACKEND_TARGET}/ai/generate`, { + method: "POST", + headers: { + "content-type": "application/json", + "x-optia-entitlement": "tok-123", + "x-optia-install-id": "install-1", + authorization: "Bearer abc", + }, + body, + }); + }); + + it("relays upstream error statuses and bodies unchanged so the app's error mapping still applies", async () => { + const errorBody = '{"error":{"code":"QUOTA_EXCEEDED","message":"AI quota reached."}}'; + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(upstream(429, errorBody))); + + const res = await proxy(fakeRequest({ method: "POST", url: "/ai/generate", body: "{}" })); + + expect(res.writeHead).toHaveBeenCalledWith(429, { + "Content-Type": "application/json", + "Access-Control-Allow-Origin": "*", + }); + expect(res.end).toHaveBeenCalledWith(errorBody); + }); + + it("answers CORS preflight itself without contacting the backend", async () => { + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const res = await proxy(fakeRequest({ method: "OPTIONS", url: "/ai/generate" })); + + expect(fetchMock).not.toHaveBeenCalled(); + expect(res.writeHead).toHaveBeenCalledWith( + 204, + expect.objectContaining({ + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": expect.stringContaining("POST"), + }), + ); + expect(res.end).toHaveBeenCalledWith(); + }); + + it("responds 502 when the backend cannot be reached", async () => { + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("fetch failed"))); + + const res = await proxy(fakeRequest({ method: "GET", url: "/health" })); + + expect(res.writeHead).toHaveBeenCalledWith(502, { "Content-Type": "text/plain" }); + expect(res.end).toHaveBeenCalledWith(expect.stringContaining("Backend proxy error")); + }); +}); diff --git a/app/vite.config.dev.ts b/app/vite.config.dev.ts index 221dae2..75003a2 100644 --- a/app/vite.config.dev.ts +++ b/app/vite.config.dev.ts @@ -169,9 +169,90 @@ function anthropicProxy(): Plugin { }; } +/** The backend the dev preview harness reaches through /api/backend. */ +export const DEV_BACKEND_TARGET = "https://optia-backend-staging.paul-130.workers.dev"; + +/** + * Vite plugin that proxies /api/backend/* to the staging Optia backend. + * + * The harness page is served from http://localhost, which staging's CORS + * allowlist rejects (it admits only the extension origin and the staging + * site), so hosted-AI, license and billing calls made straight from the page + * never get past the browser (#67). Routing them through the dev server + * sidesteps CORS the same way /api/anthropic does for BYOK calls. The app opts + * in via BACKEND_BASE_URL (src/lib/entitlement-keys.ts), which resolves to + * this path only in the harness — the extension itself never uses it. + */ +export function backendProxy(): Plugin { + // Only the request headers the backend's CORS policy accepts. Browser-added + // headers (Origin, Host, cookies, Sec-Fetch-*) stay on this side. + const forwarded = [ + "content-type", + "authorization", + "x-optia-entitlement", + "x-optia-install-id", + ]; + return { + name: "backend-proxy", + configureServer(server) { + server.middlewares.use("/api/backend", async (req, res) => { + // Handle CORS preflight (same-origin callers never send one; kept for parity) + if (req.method === "OPTIONS") { + res.writeHead(204, { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, POST, OPTIONS", + "Access-Control-Allow-Headers": forwarded.join(", "), + }); + res.end(); + return; + } + + // connect strips the mount path, so req.url is the backend route with + // its query string intact (e.g. /ai/generate, /billing/session/cs_1?installId=...). + const method = req.method ?? "GET"; + const targetUrl = `${DEV_BACKEND_TARGET}${req.url ?? ""}`; + + try { + const headers: Record = {}; + for (const name of forwarded) { + const value = req.headers[name]; + if (typeof value === "string") headers[name] = value; + } + + let body: string | undefined; + if (method !== "GET" && method !== "HEAD") { + const chunks: Buffer[] = []; + for await (const chunk of req) { + chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk); + } + body = Buffer.concat(chunks).toString(); + } + + const response = await fetch(targetUrl, { method, headers, body }); + const responseBody = await response.text(); + console.log(`[Backend Proxy] ${method} ${req.url} -> ${response.status}`); + if (!response.ok) { + console.log("[Backend Proxy] Error response:", responseBody.slice(0, 500)); + } + // Status and body pass through untouched so ai-proxy.ts / backend.ts + // map errors exactly as they would against staging directly. + res.writeHead(response.status, { + "Content-Type": response.headers.get("content-type") ?? "application/json", + "Access-Control-Allow-Origin": "*", + }); + res.end(responseBody); + } catch (err) { + res.writeHead(502, { "Content-Type": "text/plain" }); + res.end(`Backend proxy error: ${err}`); + } + }); + }, + }; +} + // Dev preview config — no CRXJS, just serves the side panel UI in a browser tab export default defineConfig({ - plugins: [react(), fetchPageProxy(), anthropicProxy()], + plugins: [react(), fetchPageProxy(), anthropicProxy(), backendProxy()], resolve: { alias: { "@": resolve(__dirname, "src"),